From f41686232743f3053492741d06b71262bd1f33d8 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 15:20:07 +0300 Subject: [PATCH 01/17] feat(server): add OpenCode 2 provider driver --- apps/mobile/src/components/ProviderIcon.tsx | 2 +- apps/server/package.json | 2 + .../src/provider/Drivers/OpenCode2Driver.ts | 140 + .../src/provider/Layers/OpenCode2Adapter.ts | 3175 +++++++++++++++++ .../src/provider/Layers/OpenCode2Provider.ts | 246 ++ .../ProviderInstanceRegistryLive.test.ts | 5 + .../provider/Layers/ProviderRegistry.test.ts | 6 + .../src/provider/Layers/ProviderRegistry.ts | 10 +- .../src/provider/Services/OpenCode2Adapter.ts | 16 + apps/server/src/provider/builtInDrivers.ts | 3 + .../opencode2Runtime.sessionRules.test.ts | 209 ++ .../src/provider/opencode2Runtime.test.ts | 154 + apps/server/src/provider/opencode2Runtime.ts | 464 +++ apps/server/src/server.ts | 4 + .../OpenCode2TextGeneration.test.ts | 129 + .../textGeneration/OpenCode2TextGeneration.ts | 219 ++ .../src/textGeneration/TextGeneration.ts | 8 +- .../components/chat/ModelPickerContent.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 4 +- apps/web/src/components/chat/TraitsPicker.tsx | 2 +- .../components/chat/composerProviderState.tsx | 2 +- .../src/components/chat/providerIconUtils.ts | 1 + .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 7 + apps/web/src/modelSelection.ts | 8 +- packages/contracts/src/model.ts | 8 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 90 + patches/@opencode__client@2.0.3.patch | 18 + pnpm-lock.yaml | 41 + pnpm-workspace.yaml | 1 + third-party-licenses.config.json | 24 + 32 files changed, 4994 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OpenCode2Driver.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Adapter.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.ts create mode 100644 apps/server/src/provider/Services/OpenCode2Adapter.ts create mode 100644 apps/server/src/provider/opencode2Runtime.sessionRules.test.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 patches/@opencode__client@2.0.3.patch diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..0b4e66adbd06 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -64,7 +64,7 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "opencode") { + if (props.provider === "opencode" || props.provider === "opencode2") { return ( diff --git a/apps/server/package.json b/apps/server/package.json index e1aef0cd76ff..5b718eacd545 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,8 @@ "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", + "@opencode/client": "2.0.3", + "@opencode/schema": "2.0.3", "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts new file mode 100644 index 000000000000..3e2c8905aa3f --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -0,0 +1,140 @@ +/** + * OpenCode2Driver — `ProviderDriver` for the OpenCode 2 background service. + * + * Mirrors the OpenCodeDriver's bundle shape (snapshot / adapter / + * textGeneration closures over the per-instance `OpenCode2Settings`), but + * there is no process to own: the adapter and probe connect to the machine's + * background service (or the configured `serverUrl`). Maintenance is + * manual-only — the v2 binary manages its own updates. + * + * @module provider/Drivers/OpenCode2Driver + */ +import { OpenCode2Settings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { makeOpenCode2TextGeneration } from "../../textGeneration/OpenCode2TextGeneration.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenCode2Adapter } from "../Layers/OpenCode2Adapter.ts"; +import { + checkOpenCode2ProviderStatus, + makePendingOpenCode2Provider, +} from "../Layers/OpenCode2Provider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenCode2Runtime } from "../opencode2Runtime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.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"); + +// The v2 binary ships its own updater; T3 never manages it. +const MANUAL_ONLY = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type OpenCode2DriverEnv = + | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | FileSystem.FileSystem + | OpenCode2Runtime + | Path.Path + | ServerConfig + | ServerSettingsService; + +export const OpenCode2Driver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode 2", + supportsMultipleInstances: true, + }, + configSchema: OpenCode2Settings, + defaultConfig: (): OpenCode2Settings => decodeOpenCode2Settings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const openCode2Runtime = yield* OpenCode2Runtime; + const serverSettings = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCode2Settings; + + const adapter = yield* makeOpenCode2Adapter(effectiveConfig, { + instanceId, + environment: processEnv, + }); + + const textGeneration = yield* makeOpenCode2TextGeneration(effectiveConfig); + + const checkProvider = checkOpenCode2ProviderStatus(effectiveConfig, serverConfig.cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCode2Runtime, openCode2Runtime), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + resolveMaintenance: () => Effect.succeed(MANUAL_ONLY), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + checkProviderOnSettingsChange: () => true, + refreshOnInterval: false, + initialSnapshot: (settings) => + makePendingOpenCode2Provider(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.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts new file mode 100644 index 000000000000..2d55389d87f3 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -0,0 +1,3175 @@ +import { + type ApprovalRequestId, + EventId, + type OpenCode2Settings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + type RuntimeMode, + ThreadId, + type ToolLifecycleItemType, + type TurnTokenUsage, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +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 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 Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { Brand } from "effect/Brand"; +import { Form, Permission, Session, SessionMessage } from "@opencode/client/effect"; +import { Mcp } from "@opencode/schema/mcp"; +import type { OpenCodeEvent } from "@opencode/client/effect"; +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 ProviderAdapterError, + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type OpenCode2AdapterShape } from "../Services/OpenCode2Adapter.ts"; +import { + buildOpenCode2SessionRules, + openCode2McpServerName, + parseOpenCode2ModelSlug, + toOpenCode2FileParts, + withOpenCode2Variant, +} from "../opencode2Runtime.ts"; +import type { OpenCode2Connection, OpenCodeClient } from "../opencode2Runtime.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; + +const PROVIDER = ProviderDriverKind.make("opencode2"); + +/** + * Version tag stamped into the OpenCode 2 resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors OPENCODE_RESUME_VERSION). + */ +const OPENCODE2_RESUME_VERSION = 1 as const; + +/** Reconnect backoff for the replay-less v2 event stream. */ +const OPENCODE2_RECONNECT_BASE_DELAY_MS = 2_000; +const OPENCODE2_RECONNECT_MAX_DELAY_MS = 30_000; +/** + * Ceiling on waiting for the first `server.connected`. The pump retries + * forever, so without this a server that accepts the health probe but never + * streams would hang session startup instead of failing it. + */ +const OPENCODE2_STREAM_READY_TIMEOUT = "15 seconds"; +/** Page guard for cursor-paginated list endpoints (upstream InvalidCursorError trap). */ +const OPENCODE2_LIST_MAX_PAGES = 200; + +/** + * Persisted resume cursor. This is the adapter's only genuinely untyped input + * (an opaque value round-tripped through ProviderService), so it is a schema + * decoded at the boundary: an unrecognized shape means "no resume" rather + * than an error, and a shape change is caught by the version literal. + */ +const OpenCode2ResumeCursor = Schema.Struct({ + schemaVersion: Schema.Literal(OPENCODE2_RESUME_VERSION), + sessionId: Schema.NonEmptyString, +}); +const decodeOpenCode2ResumeCursor = Schema.decodeUnknownOption(OpenCode2ResumeCursor); + +/** v2 brands its protocol ids; lift persisted plain strings back into them. */ +const toSessionId = (value: string): Session.ID => Session.ID.descending(value); +const toDirectory = (value: string): string & Brand<"AbsolutePath"> => + value as string & Brand<"AbsolutePath">; +const toAgentId = (value: string): string & Brand<"Agent.ID"> => + value as string & Brand<"Agent.ID">; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +const isoFromEpochMs = (value: number) => + DateTime.make(value).pipe( + Option.match({ + onNone: () => undefined, + onSome: DateTime.formatIso, + }), + ); + +/** + * Whether two directory spellings name the same location. Raw string equality + * misreads a trailing slash, `.`/`..` segment, or symlinked cwd as a cwd + * change, needlessly moving 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. Exported shape mirrors the v1 adapter's + * `isSameOpenCodeDirectory`. + */ +function isSameOpenCode2Directory( + 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, + ); +} + +/** + * Map a T3 approval decision onto OpenCode's permission reply. `always` is + * reserved for the decisions whose UI copy promises persistence + * (acceptForSession / acceptAlways); plain accepts reply `once` so a shared + * server's saved rules are never widened silently. + */ +function toOpenCode2PermissionReply(decision: ProviderApprovalDecision): Permission.Reply { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + case "acceptAlways": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +function mapPermissionDecision(reply: Permission.Reply): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +/** v2 permission actions → canonical approval request types (v1 parity). */ +function requestTypeForAction( + action: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" { + switch (action) { + case "read": + return "file_read_approval"; + case "edit": + case "write": + case "patch": + return "file_change_approval"; + default: + // Every OpenCode action needs an actionable approval in each client. + return "command_execution_approval"; + } +} + +/** + * v2 tool names → canonical tool item types. Names differ from v1 (bash → + * shell); the glob/grep read-only tools group under file_change per the v2 + * docs table. + */ +function toOpenCode2ToolItemType(toolName: string | undefined): ToolLifecycleItemType { + const normalized = (toolName ?? "").toLowerCase(); + if (normalized === "todowrite" || normalized === "todoread" || normalized === "todo") { + return "dynamic_tool_call"; + } + if (normalized === "shell" || normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized === "read" || + normalized === "edit" || + normalized === "write" || + normalized === "patch" || + normalized === "glob" || + normalized === "grep" || + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized === "websearch" || normalized === "webfetch" || 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") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +const OPENCODE2_DEFAULT_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function isOpenCode2DefaultTitle(title: string): boolean { + return OPENCODE2_DEFAULT_TITLE_PATTERN.test(title); +} + +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +/** A pending v2 permission ask, normalized out of the decoded event payload. */ +interface OpenCode2PermissionAsk { + readonly id: string; + readonly sessionID: string; + readonly action: string; + readonly resources: ReadonlyArray; + readonly metadata: Record | undefined; + readonly message: string | undefined; +} + +/** One field of a pending v2 form, with the answer key we advertise to T3. */ +interface OpenCode2FormAskField { + readonly key: string; + readonly header: string; + readonly field: OpenCode2FormField; +} + +interface OpenCode2FormAsk { + readonly id: string; + readonly sessionID: string; + readonly title: string; + readonly fields: ReadonlyArray; +} + +type OpenCode2FormField = Extract< + OpenCodeEvent, + { readonly type: "form.created" } +>["data"]["form"]["fields"][number]; + +interface OpenCode2TurnUsage { + /** Assistant message ids whose step usage was already accumulated. */ + readonly stepIds: Set; + inputTokens: number; + cachedInputTokens: number; + cacheCreationTokens: number; + outputTokens: number; + reasoningTokens: number; + hasSubagents: boolean; + complete: boolean; +} + +function makeOpenCode2TurnUsage(): OpenCode2TurnUsage { + return { + stepIds: new Set(), + inputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + hasSubagents: false, + complete: true, + }; +} + +/** + * Prompt admission guard: `session.prompt` enqueues into the server's inbox, + * so T3 must observe the session actually start executing before believing + * the turn was accepted (v1's admission guard, on v2's execution events). + */ +interface OpenCode2PromptAdmission { + readonly generation: number; + readonly turnId: TurnId; + accepted: boolean; + cancelled: boolean; + readonly acceptance: Deferred.Deferred; + recoveryFiber?: Fiber.Fiber; +} + +/** A turn whose terminal execution event may have been missed. */ +interface OpenCode2IdleReconciliation { + readonly turnId: TurnId; + readonly promptGeneration: number; + raw: unknown; + warned: boolean; + fiber?: Fiber.Fiber; +} + +interface OpenCode2SessionContext { + session: ProviderSession; + readonly client: OpenCodeClient; + readonly directory: string; + openCodeSessionId: string; + /** Root session plus subagent child sessions, all routed to this thread. */ + readonly relatedSessionIds: Set; + readonly resolvedRequestIds: Set; + readonly autoRepliedRequestIds: Set; + readonly emittedTerminalRequestIds: Set; + readonly pendingPermissions: Map; + readonly pendingForms: Map; + /** Tool call ids → tool names, learned from `session.tool.input.started`. */ + readonly toolNamesById: Map; + turnUsage: OpenCode2TurnUsage | undefined; + activeTurnId: TurnId | undefined; + /** Runtime mode whose ruleset was last written to the session. */ + appliedRulesMode: RuntimeMode | undefined; + readonly mcpServerName: string; + cancellationTurnId: TurnId | undefined; + interruptedTurnId: TurnId | undefined; + reconcileIdleStatus: boolean; + awaitingBusyAfterInterruption: boolean; + pendingIdleReconciliation: OpenCode2IdleReconciliation | undefined; + promptGeneration: number; + promptAdmission: OpenCode2PromptAdmission | undefined; + readonly promptSemaphore: Semaphore.Semaphore; + readonly firstConnection: Deferred.Deferred; + /** + * One-shot guard flipped by `stopContext` / `emitUnexpectedExit`. The + * session lifecycle is owned by `sessionScope`; this Ref exists so + * concurrent callers can race the transition safely via `getAndSet`. + */ + readonly stopped: Ref.Ref; + /** + * Sole lifecycle handle for the session. Closing this scope interrupts the + * event-pump and reconciliation fibers forked via `Effect.forkIn`. + */ + readonly sessionScope: Scope.Closeable; +} + +export interface OpenCode2AdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; +} + +export function makeOpenCode2Adapter( + openCode2Settings: OpenCode2Settings, + options?: OpenCode2AdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode2"); + const serverConfig = yield* ServerConfig; + const openCode2Runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCode2Directory(fileSystem, path, left, right); + + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + const deleteContextIfCurrent = (context: OpenCode2SessionContext) => { + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + }; + + // One connection per adapter: settings are fixed at construction, so the + // health probe runs once per adapter instead of once per session start. + let cachedConnection: OpenCode2Connection | undefined; + const connect = (): Effect.Effect => + Effect.gen(function* () { + if (cachedConnection) { + return cachedConnection; + } + const connection = yield* openCode2Runtime + .connect({ + serverUrl: openCode2Settings.serverUrl, + serverPassword: openCode2Settings.serverPassword, + binaryPath: openCode2Settings.binaryPath, + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "connect", + detail: cause.detail, + cause, + }), + ), + ); + cachedConnection = connection; + return connection; + }); + + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate OpenCode 2 runtime identifier.", + cause, + }), + ), + ); + + type EventBaseInput = { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; + }; + + 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: "opencode2.sdk.event" as const, + payload: input.raw, + }, + } + : {}), + })), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + // Synchronous publish for callers that must not yield between a state + // check and the enqueue, e.g. emitting lifecycle events right before a + // scope close that would interrupt the current fiber. + const emitUnsafe = (event: ProviderRuntimeEvent) => { + Queue.offerUnsafe(runtimeEvents, event); + }; + + /** + * Map any client error into the adapter-boundary request error. Client + * method error unions are huge; the catch-all keeps call sites readable. + */ + const toRequestError = + (method: string, detail: string) => + (cause: unknown): ProviderAdapterRequestError => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail, + cause, + }); + + /** + * The failure already crossed the adapter boundary as a + * `ProviderAdapterRequestError` (every client method is `mapError`-wrapped + * at its call site), so the cause's error carries the real detail. Defects + * and interrupts have no request error, hence the explicit fallback. + */ + const toProcessError = ( + threadId: ThreadId, + cause: Cause.Cause, + ): ProviderAdapterProcessError => { + const failure = Cause.findErrorOption(cause); + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: Option.isSome(failure) + ? failure.value.detail + : "OpenCode 2 session startup failed.", + cause: Option.isSome(failure) ? failure.value : cause, + }); + }; + + function updateProviderSession( + context: OpenCode2SessionContext, + patch: Partial, + clear?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, + ): Effect.Effect { + return Effect.map(nowIso, (updatedAt) => { + const nextSession = { + ...context.session, + ...patch, + updatedAt, + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (clear?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (clear?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; + }); + } + + const ensureSessionContext = Effect.fn("opencode2.ensureSessionContext")(function* ( + 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, + }); + } + return session; + }); + + const awaitContextReady = Effect.fn("opencode2.awaitContextReady")(function* ( + context: OpenCode2SessionContext, + ) { + yield* Deferred.await(context.firstConnection); + const current = yield* ensureSessionContext(context.session.threadId); + if (current !== context) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: context.session.threadId, + }); + } + return current; + }); + + // ── usage ────────────────────────────────────────────────────── + + function takeTurnUsage(context: OpenCode2SessionContext, complete: boolean): TurnTokenUsage { + const usage = context.turnUsage; + context.turnUsage = undefined; + if (!usage || usage.stepIds.size === 0) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: usage?.hasSubagents ?? false, + }; + } + return { + usageStatus: complete && usage.complete ? "complete" : "partial", + usageScope: "main_agent", + inputTokens: usage.inputTokens, + cachedInputTokens: usage.cachedInputTokens, + cacheCreationTokens: usage.cacheCreationTokens, + outputTokens: usage.outputTokens, + reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens), + hasSubagents: usage.hasSubagents, + }; + } + + /** + * Whether the session is currently executing anything. `session.active` + * is v2's busy probe; the session-scoped `session.usage.updated` event + * carries cumulative server totals, so per-turn usage is accumulated from + * `session.step.ended` tokens instead (one step per assistant message). + */ + const isSessionBusy = Effect.fn("opencode2.isSessionBusy")(function* ( + context: OpenCode2SessionContext, + ): Effect.fn.Return< + { readonly type: "busy" } | { readonly type: "idle" } | { readonly type: "unknown" } + > { + const active = yield* context.client.session + .active() + .pipe(Effect.timeout("2 seconds"), Effect.option); + if (Option.isNone(active)) { + return { type: "unknown" }; + } + // `session.active` is keyed by `Session.ID`; the raw id needs the same + // brand lift the rest of the adapter uses. + const running = Object.hasOwn(active.value, toSessionId(context.openCodeSessionId)); + return running ? { type: "busy" } : { type: "idle" }; + }); + + /** + * Confirm whether a prompt the server may or may not have accepted + * actually landed: reachable-and-idle means lost, reachable-and-busy means + * accepted, unreachable stays unknown after a few tries. + */ + const promptLandedOnServer = Effect.fn("opencode2.promptLandedOnServer")(function* ( + context: OpenCode2SessionContext, + ): Effect.fn.Return< + { readonly type: "busy" } | { readonly type: "idle" } | { readonly type: "unknown" } + > { + for (let attempt = 0; attempt < 3; attempt += 1) { + const busy = yield* isSessionBusy(context); + if (busy.type === "busy") return { type: "busy" }; + if (busy.type === "idle") return { type: "idle" }; + yield* Effect.sleep("500 millis"); + } + return { type: "unknown" }; + }); + + // ── turn lifecycle ──────────────────────────────────────────── + + const cancelIdleReconciliation = Effect.fn("opencode2.cancelIdleReconciliation")(function* ( + context: OpenCode2SessionContext, + ) { + const pending = context.pendingIdleReconciliation; + context.pendingIdleReconciliation = undefined; + if (pending?.fiber) { + yield* Fiber.interrupt(pending.fiber); + } + }); + + const completeTurn = Effect.fn("opencode2.completeTurn")(function* ( + context: OpenCode2SessionContext, + turnId: TurnId, + promptGeneration: number, + raw: unknown, + outcome: + | { readonly state: "completed" } + | { readonly state: "failed"; readonly errorMessage: string }, + ) { + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.activeTurnId !== turnId || + context.promptGeneration !== promptGeneration || + context.cancellationTurnId === turnId + ) { + return; + } + const pendingIdleReconciliation = context.pendingIdleReconciliation; + if ( + pendingIdleReconciliation?.turnId === turnId && + pendingIdleReconciliation.promptGeneration === promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + } + const tokenUsage = takeTurnUsage(context, outcome.state === "completed"); + context.activeTurnId = undefined; + context.interruptedTurnId = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + for (const requestId of context.autoRepliedRequestIds) { + context.emittedTerminalRequestIds.add(requestId); + } + context.autoRepliedRequestIds.clear(); + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.completed", + payload: { + state: outcome.state, + ...(outcome.state === "failed" ? { errorMessage: outcome.errorMessage } : {}), + tokenUsage, + }, + }); + }); + + const scheduleIdleReconciliation = Effect.fn("opencode2.scheduleIdleReconciliation")(function* ( + context: OpenCode2SessionContext, + turnId: TurnId, + raw: unknown, + ) { + const existing = context.pendingIdleReconciliation; + if (existing?.turnId === turnId && existing.promptGeneration === context.promptGeneration) { + existing.raw = raw; + return; + } + yield* cancelIdleReconciliation(context); + + const pending: OpenCode2IdleReconciliation = { + turnId, + promptGeneration: context.promptGeneration, + raw, + warned: false, + }; + context.pendingIdleReconciliation = pending; + const reconcile = Effect.gen(function* () { + let retryCount = 0; + while (context.pendingIdleReconciliation === pending) { + if ( + context.activeTurnId !== turnId || + context.awaitingBusyAfterInterruption || + context.promptGeneration !== pending.promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + return; + } + const busy = yield* isSessionBusy(context); + if ( + context.pendingIdleReconciliation !== pending || + context.activeTurnId !== turnId || + context.promptGeneration !== pending.promptGeneration + ) { + return; + } + if (busy.type === "idle") { + context.pendingIdleReconciliation = undefined; + yield* completeTurn(context, turnId, pending.promptGeneration, pending.raw, { + state: "completed", + }); + return; + } + if (busy.type === "busy") { + context.pendingIdleReconciliation = undefined; + return; + } + if (!pending.warned) { + pending.warned = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode 2 turn completion is waiting for session status.", + detail: "The session busy probe did not complete.", + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + } + }).pipe( + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + if (context.pendingIdleReconciliation === pending) { + context.pendingIdleReconciliation = undefined; + } + }), + ), + ); + pending.fiber = yield* reconcile.pipe(Effect.forkIn(context.sessionScope)); + }); + + const interruptTurnState = Effect.fn("opencode2.interruptTurnState")(function* ( + context: OpenCode2SessionContext, + turnId: TurnId, + raw?: unknown, + ) { + if (context.interruptedTurnId === turnId) { + return; + } + yield* cancelIdleReconciliation(context); + context.interruptedTurnId = turnId; + context.reconcileIdleStatus = true; + context.awaitingBusyAfterInterruption = false; + if (context.cancellationTurnId === turnId) { + context.cancellationTurnId = undefined; + } + let tokenUsage: TurnTokenUsage = { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }; + if (context.activeTurnId === turnId) { + tokenUsage = takeTurnUsage(context, false); + context.activeTurnId = undefined; + yield* updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true, clearLastError: true }, + ); + } + yield* closePendingRequests(context, { type: "session.interrupt" }); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + tokenUsage, + }, + }); + }); + + // ── prompt admission ────────────────────────────────────────── + + const failPromptAdmission = Effect.fn("opencode2.failPromptAdmission")(function* ( + context: OpenCode2SessionContext, + admission: OpenCode2PromptAdmission, + ) { + if ( + context.promptAdmission !== admission || + context.activeTurnId !== admission.turnId || + context.promptGeneration !== admission.generation + ) { + return; + } + const detail = + "OpenCode 2 accepted the prompt, but T3 Code could not confirm the session started executing it."; + yield* context.client.session + .interrupt({ sessionID: toSessionId(context.openCodeSessionId), continue: false }) + .pipe(Effect.timeout("1 second"), Effect.ignore); + const tokenUsage = takeTurnUsage(context, false); + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { status: "error", lastError: detail }, + { clearActiveTurnId: true }, + ); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: admission.turnId, + })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: detail, + tokenUsage, + }, + }); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: admission.turnId, + })), + type: "runtime.error", + payload: { + message: detail, + class: "transport_error", + }, + }); + }); + + const schedulePromptAdmissionRecovery = Effect.fn("opencode2.schedulePromptAdmissionRecovery")( + function* (context: OpenCode2SessionContext, admission: OpenCode2PromptAdmission) { + if (admission.recoveryFiber || admission.cancelled) { + return; + } + const recover = Effect.gen(function* () { + // Acceptance usually lands via `session.execution.started`; the + // timeout arm is the 10s admission deadline from the design. + const accepted = yield* Deferred.await(admission.acceptance).pipe( + Effect.as(true), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.succeed(false), + }), + ); + if (accepted || admission.cancelled) { + return; + } + if ( + context.promptAdmission !== admission || + context.activeTurnId !== admission.turnId || + context.promptGeneration !== admission.generation || + (yield* Ref.get(context.stopped)) + ) { + return; + } + const landed = yield* promptLandedOnServer(context); + if ( + context.promptAdmission !== admission || + context.activeTurnId !== admission.turnId || + context.promptGeneration !== admission.generation + ) { + return; + } + if (landed.type === "busy") { + admission.accepted = true; + return; + } + yield* failPromptAdmission(context, admission); + }).pipe( + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + delete admission.recoveryFiber; + }), + ), + ); + admission.recoveryFiber = yield* recover.pipe(Effect.forkIn(context.sessionScope)); + }, + ); + + const cancelPromptAdmission = Effect.fn("opencode2.cancelPromptAdmission")(function* ( + context: OpenCode2SessionContext, + ) { + const admission = context.promptAdmission; + if (!admission) { + return; + } + admission.cancelled = true; + context.promptAdmission = undefined; + if (admission.recoveryFiber) { + yield* Fiber.interrupt(admission.recoveryFiber); + } + }); + + // ── permissions & forms ─────────────────────────────────────── + + const resolvePendingRequest = (context: OpenCode2SessionContext, requestId: string) => + Effect.sync(() => { + context.resolvedRequestIds.add(requestId); + }); + + const openPermissionRequest = Effect.fn("opencode2.openPermissionRequest")(function* ( + context: OpenCode2SessionContext, + ask: OpenCode2PermissionAsk, + raw: unknown, + ) { + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.emittedTerminalRequestIds.has(ask.id) || + context.pendingPermissions.has(ask.id) + ) { + return; + } + if (context.activeTurnId === undefined && context.reconcileIdleStatus) { + context.resolvedRequestIds.add(ask.id); + return; + } + const detail = [ask.action.replaceAll("_", " "), ...ask.resources].join("\n"); + context.autoRepliedRequestIds.delete(ask.id); + context.pendingPermissions.set(ask.id, ask); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: ask.id, + raw, + })), + type: "request.opened", + payload: { + requestType: requestTypeForAction(ask.action), + ...(trimText(detail) ? { detail } : {}), + args: ask.metadata, + options: [ + { decision: "accept", label: "Allow once" }, + { + decision: "acceptForSession", + label: "Allow for workspace", + warning: "Applies to matching requests in other OpenCode sessions in this workspace.", + }, + { decision: "decline", label: "Deny" }, + ], + }, + }); + }); + + /** + * Full access means the user already granted everything, but two upstream + * paths never consult the session ruleset we send: doom-loop detection + * (evaluated against the agent ruleset only) and subagent sessions (which + * keep only deny and external-directory rules). Answer those asks here. + * + * Reply "once", not "always": on a shared server an "always" from a + * full-access thread would silently widen what a supervised thread on the + * same directory is allowed to do. + */ + const autoReplyFullAccess = Effect.fn("opencode2.autoReplyFullAccess")(function* ( + context: OpenCode2SessionContext, + ask: OpenCode2PermissionAsk, + raw: unknown, + ) { + const replied = yield* context.client.permission + .reply({ + sessionID: toSessionId(ask.sessionID), + requestID: Permission.ID.create(ask.id), + reply: "once", + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!replied) { + // Fall back to the dialog. The id stays resolved so a recovered copy + // of this ask cannot reopen after the user answers; + // `pendingPermissions` gates re-asks while the dialog is open. + yield* openPermissionRequest(context, ask, raw); + } + }); + + const emitPendingPermission = Effect.fn("opencode2.emitPendingPermission")(function* ( + context: OpenCode2SessionContext, + ask: OpenCode2PermissionAsk, + raw: unknown, + ) { + if (context.resolvedRequestIds.has(ask.id)) { + return; + } + if (context.pendingPermissions.has(ask.id)) { + return; + } + if (context.session.runtimeMode === "full-access") { + // Reply outside the event pump so a slow HTTP response cannot hide + // progress, terminal replies, or the acknowledgment for Stop. + context.resolvedRequestIds.add(ask.id); + context.autoRepliedRequestIds.add(ask.id); + yield* autoReplyFullAccess(context, ask, raw).pipe(Effect.forkIn(context.sessionScope)); + return; + } + yield* openPermissionRequest(context, ask, raw); + }); + + const emitTerminalPermission = Effect.fn("opencode2.emitTerminalPermission")(function* ( + context: OpenCode2SessionContext, + requestId: string, + reply: Permission.Reply | undefined, + raw?: unknown, + ) { + if (context.emittedTerminalRequestIds.has(requestId)) { + return; + } + if (context.autoRepliedRequestIds.delete(requestId)) { + context.emittedTerminalRequestIds.add(requestId); + return; + } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw, + }); + if (context.emittedTerminalRequestIds.has(requestId)) return; + context.emittedTerminalRequestIds.add(requestId); + const request = context.pendingPermissions.get(requestId); + context.pendingPermissions.delete(requestId); + emitUnsafe({ + ...base, + type: "request.resolved", + payload: { + requestType: request ? requestTypeForAction(request.action) : "unknown", + ...(reply ? { decision: mapPermissionDecision(reply) } : {}), + }, + }); + }); + + function questionFromFormField(field: OpenCode2FormField, index: number): UserInputQuestion { + const key = field.key.trim().length > 0 ? field.key : `field-${index}`; + const header = trimText(field.title) ?? key; + const question = trimText(field.description) ?? header; + const optionFrom = (option: { label: string; description?: string | undefined }) => ({ + label: option.label, + description: option.description ?? "", + }); + switch (field.type) { + case "string": { + const options = (field.options ?? []).map((option) => ({ + ...optionFrom(option), + value: option.value, + })); + const allowCustom = field.custom === true || options.length === 0; + return { + id: key, + header, + question, + options, + ...(allowCustom ? { allowCustomAnswer: true } : {}), + }; + } + case "multiselect": { + const options = field.options.map((option) => ({ + ...optionFrom(option), + value: option.value, + })); + return { + id: key, + header, + question, + options, + multiSelect: true, + ...(field.custom === true ? { allowCustomAnswer: true } : {}), + }; + } + case "boolean": + return { + id: key, + header, + question, + options: [ + { label: "Yes", description: "", value: "true" }, + { label: "No", description: "", value: "false" }, + ], + }; + case "number": + case "integer": + return { + id: key, + header, + question, + options: [], + allowCustomAnswer: true, + }; + case "external": + return { + id: key, + header, + question: `${question} (complete at ${field.url})`, + options: [], + allowCustomAnswer: true, + }; + } + } + + const openFormRequest = Effect.fn("opencode2.openFormRequest")(function* ( + context: OpenCode2SessionContext, + form: { + readonly id: string; + readonly sessionID: string; + readonly title: string; + readonly fields: ReadonlyArray; + }, + raw: unknown, + ) { + const stopped = yield* Ref.get(context.stopped); + if (stopped || context.pendingForms.has(form.id) || context.resolvedRequestIds.has(form.id)) { + return; + } + const fields = form.fields.map((field, index) => ({ + key: field.key.trim().length > 0 ? field.key : `field-${index}`, + header: trimText(field.title) ?? `field-${index}`, + field, + })); + const ask: OpenCode2FormAsk = { + id: form.id, + sessionID: form.sessionID, + title: form.title, + fields, + }; + context.pendingForms.set(form.id, ask); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: form.id, + raw, + })), + type: "user-input.requested", + payload: { + questions: form.fields.map((field, index) => questionFromFormField(field, index)), + }, + }); + }); + + const emitTerminalForm = Effect.fn("opencode2.emitTerminalForm")(function* ( + context: OpenCode2SessionContext, + formId: string, + answers: Record | undefined, + raw?: unknown, + ) { + if (context.emittedTerminalRequestIds.has(formId)) { + return; + } + context.emittedTerminalRequestIds.add(formId); + context.pendingForms.delete(formId); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: formId, + raw, + })), + type: "user-input.resolved", + payload: { answers: answers ?? {} }, + }); + }); + + const closePendingRequests = Effect.fn("opencode2.closePendingRequests")(function* ( + context: OpenCode2SessionContext, + raw: unknown, + options?: { + readonly skipPermissionIds?: ReadonlySet; + readonly skipFormIds?: ReadonlySet; + }, + ) { + for (const [requestId, ask] of context.pendingPermissions) { + if (options?.skipPermissionIds?.has(requestId)) continue; + yield* resolvePendingRequest(context, requestId); + if (context.emittedTerminalRequestIds.has(requestId)) continue; + context.pendingPermissions.delete(requestId); + context.emittedTerminalRequestIds.add(requestId); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw, + })), + type: "request.resolved", + payload: { requestType: requestTypeForAction(ask.action) }, + }); + } + for (const formId of context.pendingForms.keys()) { + if (options?.skipFormIds?.has(formId)) continue; + yield* resolvePendingRequest(context, formId); + yield* emitTerminalForm(context, formId, undefined, raw); + } + }); + + /** + * Reconcile pending asks against the server: close T3-side dialogs the + * server no longer tracks, and surface asks T3 never saw (emitted while + * the event stream was down). Deduped by request id. + */ + const recoverPendingRequests = Effect.fn("opencode2.recoverPendingRequests")(function* ( + context: OpenCode2SessionContext, + ) { + const permissions = yield* context.client.permission + .list({ sessionID: toSessionId(context.openCodeSessionId) }) + .pipe(Effect.timeout("10 seconds"), Effect.option); + if (Option.isSome(permissions)) { + const presentPermissionIds = new Set(permissions.value.map((ask) => ask.id)); + yield* closePendingRequests( + context, + { type: "pending-requests.recovered" }, + { + skipPermissionIds: presentPermissionIds, + }, + ); + for (const ask of permissions.value) { + yield* emitPendingPermission( + context, + { + id: ask.id, + sessionID: ask.sessionID, + action: ask.action, + resources: ask.resources, + metadata: ask.metadata, + message: ask.message, + }, + { type: "permission.asked", recovered: true, request: ask }, + ); + } + } + const forms = yield* context.client.form + .list({ sessionID: context.openCodeSessionId }) + .pipe(Effect.timeout("10 seconds"), Effect.option); + if (Option.isSome(forms)) { + const presentFormIds = new Set(forms.value.map((form) => form.id)); + yield* closePendingRequests( + context, + { type: "pending-requests.recovered" }, + { + skipFormIds: presentFormIds, + }, + ); + for (const form of forms.value) { + yield* openFormRequest( + context, + { + id: form.id, + sessionID: form.sessionID, + title: form.title, + fields: form.fields, + }, + { type: "form.created", recovered: true, form }, + ); + } + } + }); + + // ── unexpected exit & teardown ───────────────────────────────── + + const releaseContext = Effect.fn("opencode2.releaseContext")(function* ( + context: OpenCode2SessionContext, + options: { readonly interruptRemote: boolean }, + ) { + // Best-effort remote interrupt: the shared server outlives this T3 + // session, so tell it to stop any run it still owns. + if (options.interruptRemote) { + yield* context.client.session + .interrupt({ sessionID: toSessionId(context.openCodeSessionId), continue: false }) + .pipe(Effect.timeout("1 second"), Effect.ignore); + } + // Best-effort deregistration of this thread's MCP server; concurrent + // threads keep their own uniquely named registrations. + yield* context.client.mcp + .remove({ + server: context.mcpServerName, + location: { directory: toDirectory(context.directory) }, + }) + .pipe(Effect.ignore); + yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore); + }); + + const emitUnexpectedExit = Effect.fn("opencode2.emitUnexpectedExit")(function* ( + context: OpenCode2SessionContext, + message: string, + ) { + // Atomic one-shot: two fibers can race here (the event-pump reconcile + // and an interrupt failure). `getAndSet` flips the flag in a single step + // so the loser observes `true` and returns. + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode 2 session exited before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPromptAdmission(context); + const turnId = context.activeTurnId; + deleteContextIfCurrent(context); + // Emit lifecycle events BEFORE tearing down the scope via the + // synchronous publisher: closing the scope interrupts fibers forked + // into it, and any subsequent yield point would unwind and drop these. + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }); + emitUnsafe({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }); + yield* releaseContext(context, { interruptRemote: true }); + }); + + const stopContext = Effect.fn("opencode2.stopContext")(function* ( + context: OpenCode2SessionContext, + ) { + // Race-safe one-shot: first caller flips the flag, everyone else no-ops. + if (yield* Ref.getAndSet(context.stopped, true)) { + return false; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode 2 session stopped before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPromptAdmission(context); + yield* releaseContext(context, { interruptRemote: true }); + return true; + }); + + // ── event handling ──────────────────────────────────────────── + + /** + * v2 events are flat: `{ id, created, type, data, location?, metadata? }`. + * `data` is a large union whose members are not all session-scoped, so the + * id is read through a permissive schema rather than cast and picked apart + * field by field. + */ + const EventSessionRef = Schema.Struct({ + sessionID: Schema.optional(Schema.String), + form: Schema.optional(Schema.Struct({ sessionID: Schema.optional(Schema.String) })), + }); + const decodeEventSessionRef = Schema.decodeUnknownOption(EventSessionRef); + + function eventSessionId(event: OpenCodeEvent): string | undefined { + const ref = decodeEventSessionRef(event.data); + if (Option.isNone(ref)) { + return undefined; + } + return ref.value.sessionID ?? ref.value.form?.sessionID; + } + + function isRequestBearingEvent(event: OpenCodeEvent): boolean { + switch (event.type) { + case "permission.asked": + case "permission.replied": + case "form.created": + case "form.replied": + case "form.cancelled": + return true; + default: + return false; + } + } + + /** + * Bounded ancestry walk for request events arriving from a session T3 + * has not seen yet — subagent children can ask before their `created` + * event was observed, and a shared server hosts unrelated sessions whose + * asks must never surface in this thread. + */ + const isRelatedSession = Effect.fn("opencode2.isRelatedSession")(function* ( + context: OpenCode2SessionContext, + candidateSessionId: string, + ) { + if (context.relatedSessionIds.has(candidateSessionId)) { + return true; + } + const seen = new Set(); + let sessionId: string | undefined = candidateSessionId; + for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) { + if (context.relatedSessionIds.has(sessionId)) { + context.relatedSessionIds.add(candidateSessionId); + if (context.activeTurnId && context.turnUsage) { + context.turnUsage.hasSubagents = true; + } + return true; + } + if (seen.has(sessionId)) { + return false; + } + seen.add(sessionId); + const currentSessionId: string = sessionId; + const info = yield* context.client.session + .get({ sessionID: toSessionId(currentSessionId) }) + .pipe( + Effect.timeout("5 seconds"), + Effect.catchTag("SessionNotFoundError", () => + Effect.succeed(undefined as Session.Info | undefined), + ), + Effect.option, + ); + if (Option.isNone(info) || info.value === undefined) { + return false; + } + sessionId = info.value.parentID; + } + return false; + }); + + /** Records a child session of this thread (subagent binding). */ + const addRelatedSession = (context: OpenCode2SessionContext, sessionId: string) => { + if (sessionId === context.openCodeSessionId) return; + context.relatedSessionIds.add(sessionId); + if (context.activeTurnId && context.turnUsage) { + context.turnUsage.hasSubagents = true; + } + }; + + const handleSubscribedEvent = (context: OpenCode2SessionContext, event: OpenCodeEvent) => + Effect.gen(function* () { + if (event.type === "server.connected") { + // v1 recovered pending requests on reconnect; the reconnect loop + // reconciles before resubscribing, so this is informational only. + return; + } + + const sessionId = eventSessionId(event); + if (sessionId !== undefined && !context.relatedSessionIds.has(sessionId)) { + // Family events bind children by parentID below; request-bearing + // events run the ancestry walk. Both must reach the switch even + // though their session is not related (yet). + if ( + event.type !== "session.created" && + event.type !== "session.forked" && + !isRequestBearingEvent(event) + ) { + return; + } + if (isRequestBearingEvent(event)) { + const related = yield* isRelatedSession(context, sessionId); + if (!related) { + return; + } + } + } + + const turnId = context.activeTurnId; + switch (event.type) { + case "session.created": { + if (event.data.parentID && context.relatedSessionIds.has(event.data.parentID)) { + addRelatedSession(context, event.data.sessionID); + } + break; + } + case "session.forked": { + if (context.relatedSessionIds.has(event.data.parentID)) { + addRelatedSession(context, event.data.sessionID); + } + break; + } + case "session.deleted": { + context.relatedSessionIds.delete(event.data.sessionID); + break; + } + case "session.renamed": { + const title = trimText(event.data.title); + // Mirror user renames, but not OpenCode's auto-generated + // placeholders — those would lock the thread onto them. + if (title && !isOpenCode2DefaultTitle(title)) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "thread.metadata.updated", + payload: { + name: title, + metadata: { sessionID: context.openCodeSessionId }, + }, + }); + } + break; + } + case "session.execution.started": { + // v2's explicit turn-start signal: confirms prompt admission and + // marks the session busy. + const admission = context.promptAdmission; + if (admission && !admission.accepted) { + admission.accepted = true; + yield* Deferred.succeed(admission.acceptance, undefined).pipe(Effect.ignore); + } + context.awaitingBusyAfterInterruption = false; + if (turnId !== undefined) { + yield* cancelIdleReconciliation(context); + yield* updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + } + break; + } + case "session.execution.succeeded": { + if (turnId !== undefined) { + yield* completeTurn(context, turnId, context.promptGeneration, event, { + state: "completed", + }); + } + break; + } + case "session.execution.failed": { + const message = + trimText(event.data.error.message) ?? "OpenCode 2 session execution failed."; + if (turnId !== undefined) { + yield* cancelIdleReconciliation(context); + yield* completeTurn(context, turnId, context.promptGeneration, event, { + state: "failed", + errorMessage: message, + }); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.data.error, + }, + }); + } + break; + } + case "session.execution.interrupted": { + if (turnId === undefined) { + break; + } + if (event.data.reason === "user") { + yield* interruptTurnState(context, turnId, event); + } else { + // superseded (steer), shutdown, inactivity: the replacement run + // completes the same turn; only warn. + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "runtime.warning", + payload: { + message: `OpenCode 2 execution interrupted (${event.data.reason}).`, + detail: event.data, + }, + }); + } + break; + } + case "session.status": { + const status = event.data.status; + if (status.type === "busy" || status.type === "retry") { + if (turnId !== undefined) { + yield* cancelIdleReconciliation(context); + context.awaitingBusyAfterInterruption = false; + const admission = context.promptAdmission; + if (admission && !admission.accepted) { + admission.accepted = true; + yield* Deferred.succeed(admission.acceptance, undefined).pipe(Effect.ignore); + } + yield* updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + } + if (status.type === "retry") { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "runtime.warning", + payload: { + message: `OpenCode 2 retry ${status.attempt}: ${status.message}`, + detail: status, + }, + }); + } + break; + } + // idle: reconciliation only — the explicit execution events own + // turn completion, this covers a missed terminal event. + if (turnId !== undefined) { + const admission = context.promptAdmission; + if (admission?.turnId === turnId) { + yield* schedulePromptAdmissionRecovery(context, admission); + break; + } + if (context.awaitingBusyAfterInterruption) { + break; + } + if (context.reconcileIdleStatus) { + yield* scheduleIdleReconciliation(context, turnId, event); + break; + } + yield* completeTurn(context, turnId, context.promptGeneration, event, { + state: "completed", + }); + } + break; + } + case "session.idle": { + if (turnId !== undefined) { + const admission = context.promptAdmission; + if (admission?.turnId === turnId) { + yield* schedulePromptAdmissionRecovery(context, admission); + break; + } + yield* scheduleIdleReconciliation(context, turnId, event); + } + break; + } + case "session.text.started": + case "session.reasoning.started": { + if (turnId !== undefined) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: `${event.data.assistantMessageID}:${event.data.ordinal}`, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.started", + payload: { + itemType: + event.type === "session.text.started" ? "assistant_message" : "reasoning", + status: "inProgress", + title: event.type === "session.text.started" ? "Assistant message" : "Reasoning", + }, + }); + } + break; + } + case "session.text.delta": + case "session.reasoning.delta": { + if (turnId !== undefined && event.data.delta.length > 0) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: `${event.data.assistantMessageID}:${event.data.ordinal}`, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "content.delta", + payload: { + streamKind: + event.type === "session.text.delta" ? "assistant_text" : "reasoning_text", + delta: event.data.delta, + }, + }); + } + break; + } + case "session.text.ended": + case "session.reasoning.ended": { + if (turnId !== undefined) { + const isText = event.type === "session.text.ended"; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: `${event.data.assistantMessageID}:${event.data.ordinal}`, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.completed", + payload: { + itemType: isText ? "assistant_message" : "reasoning", + status: "completed", + title: isText ? "Assistant message" : "Reasoning", + ...(event.data.text.length > 0 ? { detail: event.data.text } : {}), + }, + }); + } + break; + } + case "session.tool.input.started": { + context.toolNamesById.set(event.data.id, event.data.name); + break; + } + case "session.tool.called": { + if (turnId !== undefined) { + const toolName = context.toolNamesById.get(event.data.id); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.data.id, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.started", + payload: { + itemType: toOpenCode2ToolItemType(toolName), + status: "inProgress", + ...(toolName ? { title: toolName } : {}), + data: { + ...(toolName ? { tool: toolName } : {}), + input: event.data.input, + executed: event.data.executed, + }, + }, + }); + } + break; + } + case "session.tool.progress": { + if (turnId !== undefined) { + const toolName = context.toolNamesById.get(event.data.id); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.data.id, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.updated", + payload: { + itemType: toOpenCode2ToolItemType(toolName), + status: "inProgress", + ...(toolName ? { title: toolName } : {}), + data: event.data.metadata, + }, + }); + } + break; + } + case "session.tool.success": { + if (turnId !== undefined) { + const toolName = context.toolNamesById.get(event.data.id); + const output = event.data.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + const itemType = toOpenCode2ToolItemType(toolName); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.data.id, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.completed", + payload: { + itemType, + status: "completed", + ...(toolName ? { title: toolName } : {}), + ...(output.length > 0 ? { detail: output } : {}), + data: { + ...(toolName ? { tool: toolName } : {}), + ...(itemType === "command_execution" || itemType === "mcp_tool_call" + ? { result: output } + : {}), + }, + }, + }); + } + break; + } + case "session.tool.failed": { + if (turnId !== undefined) { + const toolName = context.toolNamesById.get(event.data.id); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.data.id, + createdAt: isoFromEpochMs(event.created), + raw: event, + })), + type: "item.completed", + payload: { + itemType: toOpenCode2ToolItemType(toolName), + status: "failed", + ...(toolName ? { title: toolName } : {}), + detail: event.data.error.message, + }, + }); + } + break; + } + case "session.step.ended": { + // One step per assistant message; the per-step tokens accumulate + // into the turn usage like v1's step-finish accumulator. Only the + // root session counts — the usage scope is main_agent. + if ( + turnId !== undefined && + context.turnUsage && + event.data.sessionID === context.openCodeSessionId + ) { + const usage = context.turnUsage; + if (!usage.stepIds.has(event.data.assistantMessageID)) { + usage.stepIds.add(event.data.assistantMessageID); + const tokens = event.data.tokens; + usage.inputTokens += tokens.input + tokens.cache.read + tokens.cache.write; + usage.cachedInputTokens += tokens.cache.read; + usage.cacheCreationTokens += tokens.cache.write; + usage.outputTokens += tokens.output + tokens.reasoning; + usage.reasoningTokens += tokens.reasoning; + } + } + break; + } + case "session.retry.scheduled": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "runtime.warning", + payload: { + message: `OpenCode 2 retry ${event.data.attempt}: ${event.data.error.message}`, + detail: event.data.error, + }, + }); + break; + } + case "session.compaction.ended": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "thread.state.changed", + payload: { + state: "compacted", + detail: event, + }, + }); + break; + } + case "permission.asked": { + const ask = event.data; + yield* emitPendingPermission( + context, + { + id: ask.id, + sessionID: ask.sessionID, + action: ask.action, + resources: ask.resources, + metadata: ask.metadata, + message: ask.message, + }, + event, + ); + break; + } + case "permission.replied": { + yield* resolvePendingRequest(context, event.data.requestID); + yield* emitTerminalPermission(context, event.data.requestID, event.data.reply, event); + break; + } + case "form.created": { + yield* openFormRequest( + context, + { + id: event.data.form.id, + sessionID: event.data.form.sessionID, + title: event.data.form.title, + fields: event.data.form.fields, + }, + event, + ); + break; + } + case "form.replied": { + yield* resolvePendingRequest(context, event.data.id); + yield* emitTerminalForm(context, event.data.id, event.data.answer, event); + break; + } + case "form.cancelled": { + yield* resolvePendingRequest(context, event.data.id); + yield* emitTerminalForm(context, event.data.id, undefined, event); + break; + } + default: + break; + } + }); + + /** + * Reconcile everything the missed stream interval may have changed. + * Returns whether the pump should keep reconnecting; `gone` means the + * session itself no longer exists, which is the v2 analog of v1's + * unexpected server exit. + */ + const reconcileAfterReconnect = Effect.fn("opencode2.reconcileAfterReconnect")(function* ( + context: OpenCode2SessionContext, + ): Effect.fn.Return<"alive" | "gone" | "unknown", ProviderAdapterRequestError> { + const info = yield* context.client.session + .get({ sessionID: toSessionId(context.openCodeSessionId) }) + .pipe(Effect.timeout("5 seconds"), Effect.result); + if (Result.isFailure(info)) { + // The decoded failure union is tagged, so a plain tag check is enough. + if (info.failure._tag === "SessionNotFoundError") { + yield* emitUnexpectedExit( + context, + "The OpenCode 2 session no longer exists on the server.", + ); + return "gone"; + } + return "unknown"; + } + // Session rules are the supervised-mode guarantee; re-assert them in + // case the shared server restarted with different config. Sent even when + // empty so a session that moved to full-access gets its rules cleared. + if (context.appliedRulesMode) { + const ruleset = buildOpenCode2SessionRules({ + runtimeMode: context.appliedRulesMode, + ownMcpServerName: context.mcpServerName, + }); + yield* context.client.permission + .rules({ + sessionID: toSessionId(context.openCodeSessionId), + permissions: ruleset, + }) + .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); + } + yield* recoverPendingRequests(context); + if (context.activeTurnId !== undefined) { + const turnId = context.activeTurnId; + const busy = yield* isSessionBusy(context); + if (context.turnUsage) { + // Any step totals from the blackout are unreliable. + context.turnUsage.complete = false; + } + if (busy.type === "idle") { + yield* completeTurn( + context, + turnId, + context.promptGeneration, + { type: "execution.reconciled" }, + { state: "completed" }, + ); + } + } + return "alive"; + }); + + /** + * The v2 event stream has no retry or replay, so the subscription sits in + * a reconnect loop: on stream failure OR clean EOF (the shared server + * outlives T3 sessions, so EOF is transport trouble, not a lifecycle + * event) warn once, back off exponentially, reconcile, and resubscribe. + */ + const startEventPump = Effect.fn("opencode2.startEventPump")(function* ( + context: OpenCode2SessionContext, + ) { + const run = Effect.gen(function* () { + let attempt = 0; + // The health probe only proves the HTTP API answers; the event stream + // is a separate subscription. Because v2 streams have no replay, a + // prompt admitted during the gap would lose its output permanently — + // so `firstConnection` resolves on `server.connected` (the first event + // of every subscription), not when the pump merely starts. + while (!(yield* Ref.get(context.stopped))) { + yield* context.client.event.subscribe().pipe( + Stream.filter((event) => { + const sessionId = eventSessionId(event); + if (sessionId === undefined || context.relatedSessionIds.has(sessionId)) { + return true; + } + // Not yet related — but these events are how an unrelated + // session BECOMES related: session.created/session.forked + // carry the parentID that binds a subagent child, and + // request-bearing events run the ancestry walk. Everything + // else from unrelated sessions is dropped here to keep the + // shared server's other traffic out of this thread. + if (event.type === "session.created" || event.type === "session.forked") { + return true; + } + return isRequestBearingEvent(event); + }), + Stream.tap((event) => + // `Deferred.succeed` is idempotent, so resolving on every + // (re)connect needs no extra guard, and it will not override a + // failure already recorded by `emitUnexpectedExit`. + event.type === "server.connected" + ? Deferred.succeed(context.firstConnection, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.runForEach((event) => handleSubscribedEvent(context, event)), + Effect.exit, + ); + if (yield* Ref.get(context.stopped)) { + return; + } + if (attempt === 0) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode 2 event stream disconnected. Reconnecting.", + }, + }); + } + const delayMs = Math.min( + OPENCODE2_RECONNECT_BASE_DELAY_MS * 2 ** attempt, + OPENCODE2_RECONNECT_MAX_DELAY_MS, + ); + yield* Effect.sleep(`${delayMs} millis`); + const reconciled = yield* reconcileAfterReconnect(context); + if (reconciled === "gone") { + return; + } + attempt = reconciled === "alive" ? 0 : attempt + 1; + } + }).pipe(Effect.ignoreCause); + yield* run.pipe(Effect.forkIn(context.sessionScope)); + }); + + // ── session rules & MCP registration ─────────────────────────── + + const applySessionRules = ( + context: OpenCode2SessionContext, + runtimeMode: RuntimeMode, + ): Effect.Effect => + Effect.gen(function* () { + const ruleset = buildOpenCode2SessionRules({ + runtimeMode, + ownMcpServerName: context.mcpServerName, + }); + yield* context.client.permission + .rules({ + sessionID: toSessionId(context.openCodeSessionId), + permissions: ruleset, + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError(toRequestError("permission.rules", "Failed to set session rules.")), + ); + context.appliedRulesMode = runtimeMode; + }); + + /** + * Per-thread-named MCP registration: each thread registers + * `-` on the connected server carrying its own + * per-thread credential, so concurrent threads in one directory cannot + * reach each other's tools and cleanup is a plain remove. + * + * The `params._meta.sessionID` design was abandoned: OpenCode does not + * stamp `_meta` on `tools/call` (anomalyco/opencode#45997 is still an + * open feature request), so the per-call thread identity must ride the + * registration's Authorization header instead. Revisit if upstream ships + * `_meta` — one global registration would then replace this. + */ + const registerThreadMcp = ( + context: OpenCode2SessionContext, + ): Effect.Effect => + Effect.gen(function* () { + const mcpSession = McpProviderSession.readMcpProviderSession(context.session.threadId); + if (!mcpSession) { + return; + } + const serverName = context.mcpServerName; + const endpoint = + openCode2Settings.mcpEndpointUrl.trim().length > 0 + ? openCode2Settings.mcpEndpointUrl.trim() + : mcpSession.endpoint; + yield* context.client.mcp + .add({ + server: serverName, + location: { directory: toDirectory(context.directory) }, + // The generated client's payload encode expects the tagged-union + // class instance, not a plain object (plain objects fail with a + // misleading `Expected Mcp.RemoteConfig` SchemaError). + config: new Mcp.RemoteConfig({ + type: "remote", + url: endpoint, + headers: { Authorization: mcpSession.authorizationHeader }, + oauth: false, + }), + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError("mcp.add", "Failed to register the T3 Code MCP server."), + ), + ); + }); + + // ── message listing ─────────────────────────────────────────── + + /** + * Cursor-paginated message listing. NEVER send `order` together with a + * cursor — that combination is the upstream `InvalidCursorError`; follow + * `cursor.next` without it, and stop on an empty page, an absent cursor, + * or the page guard. + */ + const listSessionMessages = ( + context: OpenCode2SessionContext, + ): Effect.Effect, ProviderAdapterRequestError> => + Effect.gen(function* () { + const messages: Array = []; + let cursor: string | undefined = undefined; + const listRequestError = toRequestError( + "message.list", + "Failed to list OpenCode 2 messages.", + ); + for (let page = 0; page < OPENCODE2_LIST_MAX_PAGES; page += 1) { + const request: { + readonly sessionID: ReturnType; + readonly order?: "asc" | "desc"; + readonly cursor?: string; + } = { + sessionID: toSessionId(context.openCodeSessionId), + ...(cursor === undefined ? { order: "asc" as const } : { cursor }), + }; + const result = yield* context.client.message.list(request).pipe( + Effect.mapError(listRequestError), + Effect.map((response) => ({ + data: response.data as ReadonlyArray, + next: response.cursor.next, + })), + ); + messages.push(...result.data); + if (result.data.length === 0 || result.next === undefined) { + break; + } + cursor = result.next; + } + return messages; + }); + + const readThreadSnapshot = ( + context: OpenCode2SessionContext, + ): Effect.Effect< + { + readonly turns: ReadonlyArray<{ + readonly id: TurnId; + readonly items: ReadonlyArray; + }>; + readonly messages: ReadonlyArray; + }, + ProviderAdapterRequestError + > => + Effect.gen(function* () { + const sessionInfo = yield* context.client.session + .get({ sessionID: toSessionId(context.openCodeSessionId) }) + .pipe( + Effect.mapError( + toRequestError("session.get", "Failed to read the OpenCode 2 session."), + ), + ); + const messages = yield* listSessionMessages(context); + const turns: Array<{ readonly id: TurnId; readonly items: ReadonlyArray }> = []; + for (const message of messages) { + if (message.id === sessionInfo.revert?.messageID) { + break; + } + if (message.type === "assistant") { + turns.push({ + id: TurnId.make(message.id), + items: [message], + }); + } + } + return { turns, messages }; + }); + + // ── adapter surface ─────────────────────────────────────────── + + // Plain function (not `Effect.fn`): tsgo fails to infer `Effect.fn` + // against the huge client-generated union types here. + const startSession: OpenCode2AdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const directory = input.cwd ?? serverConfig.cwd; + const resumeCursor = decodeOpenCode2ResumeCursor(input.resumeCursor); + const resumeSessionId = Option.isSome(resumeCursor) + ? resumeCursor.value.sessionId + : undefined; + const existing = sessions.get(input.threadId); + if (existing) { + if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) { + return (yield* awaitContextReady(existing)).session; + } + yield* stopContext(existing); + deleteContextIfCurrent(existing); + } + + const sessionScope = yield* Scope.make(); + const mcpServerName = openCode2McpServerName( + openCode2Settings.mcpServerName, + input.threadId, + ); + const startedExit = yield* Effect.exit( + Effect.gen(function* () { + const connection = yield* connect(); + const client = connection.client; + const ruleset = buildOpenCode2SessionRules({ + runtimeMode: input.runtimeMode, + ownMcpServerName: mcpServerName, + }); + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const parsedModel = withOpenCode2Variant( + parseOpenCode2ModelSlug(input.modelSelection?.model), + getModelSelectionStringOptionValue(input.modelSelection, "variant"), + ); + + // Resume: re-adopt the session named by the durable cursor — + // OpenCode 2 scopes history by session id on the shared server. + // Only a confirmed missing session may silently start fresh; every + // other failure propagates, or a transient blip would reset a live + // thread to an empty one (#3604 silent context loss). + const adopted = resumeSessionId + ? yield* client.session.get({ sessionID: toSessionId(resumeSessionId) }).pipe( + // Typed on the decoded error union, before the boundary map + // collapses the rest into a request error. + Effect.catchTag("SessionNotFoundError", () => + Effect.succeed(undefined as Session.Info | undefined), + ), + Effect.mapError( + toRequestError("session.get", "Failed to read the OpenCode 2 session."), + ), + ) + : undefined; + + if (adopted) { + // Reuse in place only when the session still matches the + // requested cwd; otherwise move it — v2's `session.fork` takes no + // directory, and `session.move` carries the full history (v1 + // forked into the new directory for the same effect). + if (!(yield* sameDirectory(adopted.location.directory, directory))) { + yield* Effect.logInfo( + `OpenCode 2 session '${adopted.id}' was created under a different working directory; moving it into '${directory}' to preserve conversation history.`, + ); + yield* client.session + .move({ + sessionID: adopted.id, + directory: toDirectory(directory), + }) + .pipe( + Effect.mapError( + toRequestError( + "session.move", + "Failed to move the OpenCode 2 session into the requested directory.", + ), + ), + ); + } + // Resume skips `session.create`, so re-assert the ruleset — a + // runtime-mode change would otherwise leave the session on its + // original permissions. An empty ruleset is sent deliberately: + // it is what clears a supervised session's rules when the thread + // has since moved to full-access. + yield* client.permission + .rules({ sessionID: adopted.id, permissions: ruleset }) + .pipe( + Effect.mapError( + toRequestError( + "permission.rules", + "Failed to re-apply session rules on resume.", + ), + ), + ); + if (agent) { + yield* client.session + .switchAgent({ sessionID: adopted.id, agent: toAgentId(agent) }) + .pipe( + Effect.mapError( + toRequestError("session.switchAgent", "Failed to select the agent."), + ), + ); + } + if (parsedModel) { + yield* client.session + .switchModel({ sessionID: adopted.id, model: parsedModel }) + .pipe( + Effect.mapError( + toRequestError("session.switchModel", "Failed to select the model."), + ), + ); + } + return { connection, sessionInfo: adopted, created: false }; + } + + if (resumeSessionId) { + yield* Effect.logWarning( + `OpenCode 2 session '${resumeSessionId}' no longer exists; starting a fresh session.`, + ); + } + const sessionInfo = yield* client.session + .create({ + ...(input.title ? { title: input.title } : {}), + location: { directory: toDirectory(directory) }, + ...(ruleset.length > 0 ? { permissions: ruleset } : {}), + ...(agent ? { agent: toAgentId(agent) } : {}), + ...(parsedModel ? { model: parsedModel } : {}), + }) + .pipe( + Effect.mapError( + toRequestError("session.create", "OpenCode 2 session creation failed."), + ), + ); + return { connection, sessionInfo, created: true }; + }), + ); + + if (Exit.isFailure(startedExit)) { + yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); + return yield* toProcessError(input.threadId, startedExit.cause); + } + const started = startedExit.value; + const { sessionInfo, created } = started; + + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + 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, so follow-ups + // continue the same conversation. + resumeCursor: { + schemaVersion: OPENCODE2_RESUME_VERSION, + sessionId: sessionInfo.id, + }, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCode2SessionContext = { + session, + client: started.connection.client, + directory, + openCodeSessionId: sessionInfo.id, + relatedSessionIds: new Set([sessionInfo.id]), + resolvedRequestIds: new Set(), + autoRepliedRequestIds: new Set(), + emittedTerminalRequestIds: new Set(), + pendingPermissions: new Map(), + pendingForms: new Map(), + toolNamesById: new Map(), + turnUsage: undefined, + activeTurnId: undefined, + appliedRulesMode: input.runtimeMode, + mcpServerName, + cancellationTurnId: undefined, + interruptedTurnId: undefined, + reconcileIdleStatus: false, + awaitingBusyAfterInterruption: false, + pendingIdleReconciliation: undefined, + promptGeneration: 0, + promptAdmission: undefined, + promptSemaphore: Semaphore.makeUnsafe(1), + firstConnection: Deferred.makeUnsafe(), + stopped: yield* Ref.make(false), + sessionScope, + }; + + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another start published first. A newly created remote session + // belongs to this loser; a resumed one is shared upstream state. + if (yield* Ref.getAndSet(context.stopped, true)) { + return (yield* awaitContextReady(raceWinner)).session; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode 2 session startup ended before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* releaseContext(context, { interruptRemote: created }); + return (yield* awaitContextReady(raceWinner)).session; + } + sessions.set(input.threadId, context); + + const cleanupStartingContext = Effect.gen(function* () { + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode 2 session startup was cancelled.", + }), + ).pipe(Effect.ignore); + yield* cancelPromptAdmission(context); + yield* releaseContext(context, { interruptRemote: created }); + }).pipe(Effect.ensuring(Effect.sync(() => deleteContextIfCurrent(context)))); + + // Session environment (v2's analog of the spawned-server env merge). + // `options.environment` is already the merged instance env; the start + // input carries no per-session env, so there is nothing further to fold + // in here. + const environmentVariables = Object.fromEntries( + Object.entries(options?.environment ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + if (Object.keys(environmentVariables).length > 0) { + yield* context.client.session + .environment({ + sessionID: toSessionId(context.openCodeSessionId), + variables: environmentVariables, + }) + .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); + } + + // Registration failure must not kill the session, but a silent loss of + // the whole t3-code toolkit is the kind of thing users notice hours + // later — surface it as a runtime warning instead. + yield* registerThreadMcp(context).pipe( + Effect.catch((cause) => + Effect.gen(function* () { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "runtime.warning", + payload: { + message: + "Failed to register the T3 Code MCP server; agents will not see t3-code tools this session.", + }, + }); + void cause; + }), + ), + ); + const connectionExit = yield* Effect.gen(function* () { + yield* startEventPump(context); + // Wait for a live subscription, bounded so an unreachable-but- + // answering server fails session start rather than hanging it. + yield* Deferred.await(context.firstConnection).pipe( + Effect.timeoutOrElse({ + duration: OPENCODE2_STREAM_READY_TIMEOUT, + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: `OpenCode 2 event stream did not connect within ${OPENCODE2_STREAM_READY_TIMEOUT}.`, + }), + ), + }), + ); + }).pipe( + Effect.onInterrupt(() => cleanupStartingContext), + Effect.exit, + ); + if (Exit.isFailure(connectionExit)) { + yield* cleanupStartingContext; + return yield* Effect.failCause(connectionExit.cause); + } + yield* awaitContextReady(context); + if (!created) { + yield* recoverPendingRequests(context); + } + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "OpenCode 2 session started", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: context.openCodeSessionId, + }, + }); + + return context.session; + }); + + const sendTurn: OpenCode2AdapterShape["sendTurn"] = Effect.fn("opencode2.sendTurn")( + function* (input) { + const context = yield* ensureSessionContext(input.threadId); + yield* awaitContextReady(context); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { instanceId: boundInstanceId, model: context.session.model } + : undefined); + if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`, + }); + } + const parsedModel = withOpenCode2Variant( + parseOpenCode2ModelSlug(modelSelection?.model), + getModelSelectionStringOptionValue(modelSelection, "variant"), + ); + if (modelSelection !== undefined && modelSelection.model && !parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + // v2 ingests files natively as URI attachments (the runtime's part + // builder already applies the v1 native-file gating rules). + const fileParts = toOpenCode2FileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }), + }).map((part) => ({ uri: part.url, name: part.name })); + if (!text && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 turns require text input or at least one attachment.", + }); + } + + return yield* context.promptSemaphore.withPermit( + Effect.gen(function* () { + if (sessions.get(input.threadId) !== context || (yield* Ref.get(context.stopped))) { + return yield* Effect.interrupt; + } + const pendingIdleReconciliation = context.pendingIdleReconciliation; + const priorAwaitingBusy = context.awaitingBusyAfterInterruption; + context.pendingIdleReconciliation = undefined; + + // Re-apply session rules when the runtime mode changed since they + // were last written (mode switches re-enter through startSession, + // but a mid-session change must not linger on stale rules). + if (context.appliedRulesMode !== context.session.runtimeMode) { + yield* applySessionRules(context, context.session.runtimeMode); + } + + // A sendTurn while a turn is active is a steer; the active turn + // id is reused and the prompt is delivered as a steer. + const steeringTurnId = context.activeTurnId; + const turnId = steeringTurnId ?? TurnId.make(`opencode2-turn-${yield* randomUUIDv4}`); + const promptGeneration = context.promptGeneration + 1; + const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); + // Plan-mode fix: OpenCode persists the session's agent, so a turn + // that does not select one must still send the intended agent + // explicitly — omission would stick the session on `plan`. + const intendedAgent = agent ?? (input.interactionMode === "plan" ? "plan" : "build"); + + yield* context.client.session + .switchAgent({ + sessionID: toSessionId(context.openCodeSessionId), + agent: toAgentId(intendedAgent), + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError("session.switchAgent", "Failed to select the agent."), + ), + ); + if (parsedModel) { + yield* context.client.session + .switchModel({ + sessionID: toSessionId(context.openCodeSessionId), + model: parsedModel, + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError("session.switchModel", "Failed to select the model."), + ), + ); + } + + const admission: OpenCode2PromptAdmission = { + generation: promptGeneration, + turnId, + accepted: false, + cancelled: false, + acceptance: Deferred.makeUnsafe(), + }; + context.promptGeneration = promptGeneration; + context.promptAdmission = admission; + context.activeTurnId = turnId; + if (steeringTurnId === undefined) { + context.turnUsage = makeOpenCode2TurnUsage(); + } + if (steeringTurnId === undefined) { + context.awaitingBusyAfterInterruption = context.interruptedTurnId !== undefined; + } else { + context.awaitingBusyAfterInterruption = priorAwaitingBusy; + } + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + yield* updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + if (steeringTurnId === undefined) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model ?? undefined, + }, + }); + } + + const promptExit = yield* Effect.exit( + context.client.session + .prompt({ + sessionID: toSessionId(context.openCodeSessionId), + text: text ?? "", + ...(fileParts.length > 0 ? { files: fileParts } : {}), + ...(steeringTurnId !== undefined ? { delivery: "steer" as const } : {}), + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError("session.prompt", "OpenCode 2 prompt submission failed."), + ), + ), + ); + const intentionallyCancelled = + admission.cancelled || + (yield* Ref.get(context.stopped)) || + sessions.get(input.threadId) !== context; + if (Exit.isFailure(promptExit) && !intentionallyCancelled) { + // The call failed or timed out, but the server may have + // accepted the prompt anyway — reconcile before failing. + const landed = yield* promptLandedOnServer(context); + if (landed.type === "idle" || landed.type === "unknown") { + // The failure is already a mapped `ProviderAdapterRequestError`, + // so its `detail` is the user-facing text. + const promptFailure = Cause.findErrorOption(promptExit.cause); + const requestErrorDetail = Option.isSome(promptFailure) + ? promptFailure.value.detail + : "OpenCode 2 prompt submission failed."; + const tokenUsage = takeTurnUsage(context, false); + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestErrorDetail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { + reason: requestErrorDetail, + tokenUsage, + }, + }); + return yield* Effect.failCause(promptExit.cause); + } + // Landed despite the API error: the event stream drives the + // turn from here. + } + + yield* schedulePromptAdmissionRecovery(context, admission); + + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + sessions.get(input.threadId) !== context || + admission.cancelled || + context.activeTurnId !== turnId || + context.promptGeneration !== admission.generation + ) { + if (context.promptAdmission === admission) { + context.promptAdmission = undefined; + } + return yield* Effect.interrupt; + } + + return { + threadId: input.threadId, + turnId, + // Re-surface the durable cursor on every turn so the persisted + // binding is refreshed alongside last-seen state. + resumeCursor: { + schemaVersion: OPENCODE2_RESUME_VERSION, + sessionId: context.openCodeSessionId, + }, + }; + }), + ); + }, + ); + + const compactThread = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const context = yield* ensureSessionContext(threadId); + yield* awaitContextReady(context); + yield* context.promptSemaphore.withPermit( + Effect.gen(function* () { + if (sessions.get(threadId) !== context || (yield* Ref.get(context.stopped))) { + return yield* Effect.interrupt; + } + if (context.activeTurnId !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "compactThread", + issue: "OpenCode 2 cannot compact while a turn is running.", + }); + } + // v2 compaction runs on the session's current model; the compact + // input has no model override. + yield* context.client.session + .compact({ sessionID: toSessionId(context.openCodeSessionId) }) + .pipe( + Effect.timeout("10 minutes"), + Effect.mapError( + toRequestError( + "session.compact", + "OpenCode 2 session compaction did not complete within 10 minutes.", + ), + ), + Effect.asVoid, + ); + }), + ); + }); + + const interruptTurn: OpenCode2AdapterShape["interruptTurn"] = Effect.fn( + "opencode2.interruptTurn", + )(function* (threadId, turnId) { + const context = yield* ensureSessionContext(threadId); + const activeTurnId = context.activeTurnId; + if (turnId !== undefined && activeTurnId !== turnId) { + return; + } + const targetTurnId = turnId ?? activeTurnId; + if (targetTurnId && context.interruptedTurnId === targetTurnId) { + return; + } + yield* cancelIdleReconciliation(context); + + // A pending admission's recovery must not fight the interrupt. + const admission = context.promptAdmission; + if (admission !== undefined && admission.turnId === targetTurnId) { + yield* cancelPromptAdmission(context); + } + if (targetTurnId) { + context.cancellationTurnId = targetTurnId; + } + + yield* context.client.session + .interrupt({ + sessionID: toSessionId(context.openCodeSessionId), + continue: false, + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError( + "session.interrupt", + "OpenCode 2 session interrupt did not complete within 10 seconds.", + ), + ), + ); + if (context.cancellationTurnId === targetTurnId) { + context.cancellationTurnId = undefined; + } + // The `session.execution.interrupted` event usually drives the abort; + // emit locally too so a stream outage cannot strand a running turn. + if (targetTurnId) { + yield* interruptTurnState(context, targetTurnId); + } + }); + + const respondToRequest: OpenCode2AdapterShape["respondToRequest"] = Effect.fn( + "opencode2.respondToRequest", + )(function* (threadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { + const context = yield* ensureSessionContext(threadId); + const ask = context.pendingPermissions.get(requestId); + if (!ask) { + if (context.emittedTerminalRequestIds.has(requestId)) return; + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const reply = toOpenCode2PermissionReply(decision); + yield* context.client.permission + .reply({ + sessionID: toSessionId(ask.sessionID), + requestID: Permission.ID.create(requestId), + reply, + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError( + "permission.reply", + "OpenCode 2 permission reply did not complete within 10 seconds.", + ), + ), + ); + yield* resolvePendingRequest(context, requestId); + yield* emitTerminalPermission(context, requestId, reply, { + type: "permission.reply", + requestID: requestId, + reply, + }); + }); + + const respondToUserInput: OpenCode2AdapterShape["respondToUserInput"] = Effect.fn( + "opencode2.respondToUserInput", + )(function* (threadId, requestId: ApprovalRequestId, answers: ProviderUserInputAnswers) { + const context = yield* ensureSessionContext(threadId); + const form = context.pendingForms.get(requestId); + if (!form) { + if (context.emittedTerminalRequestIds.has(requestId)) return; + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "form.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + const answer: Record = {}; + for (const field of form.fields) { + const raw = + answers[field.key] ?? answers[field.header] ?? answers[trimText(field.field.title) ?? ""]; + if (raw === undefined || raw === null) { + continue; + } + switch (field.field.type) { + case "multiselect": { + if (Array.isArray(raw)) { + const values = raw.filter((value): value is string => typeof value === "string"); + if (values.length > 0) { + answer[field.key] = values; + } + } else if (typeof raw === "string" && raw.trim().length > 0) { + answer[field.key] = [raw]; + } + break; + } + case "boolean": { + if (typeof raw === "boolean") { + answer[field.key] = raw; + } else if (raw === "true" || raw === "false") { + answer[field.key] = raw === "true"; + } + break; + } + case "number": + case "integer": { + const value = typeof raw === "number" ? raw : Number(raw); + if (Number.isFinite(value)) { + answer[field.key] = value; + } + break; + } + case "string": + case "external": { + const value = String(raw); + if (value.trim().length > 0) { + answer[field.key] = value; + } + break; + } + } + } + + yield* context.client.form + .reply({ + sessionID: form.sessionID, + formID: Form.ID.create(form.id), + answer, + }) + .pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + toRequestError( + "form.reply", + "OpenCode 2 form reply did not complete within 10 seconds.", + ), + ), + ); + yield* resolvePendingRequest(context, form.id); + yield* emitTerminalForm(context, form.id, answer, { + type: "form.reply", + formID: form.id, + }); + }); + + const stopSession: OpenCode2AdapterShape["stopSession"] = Effect.fn("opencode2.stopSession")( + function* (threadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + const stopped = yield* stopContext(context); + deleteContextIfCurrent(context); + if (!stopped) { + return; + } + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCode2AdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCode2AdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCode2AdapterShape["readThread"] = Effect.fn("opencode2.readThread")( + function* (threadId) { + const context = yield* ensureSessionContext(threadId); + const snapshot = yield* readThreadSnapshot(context); + return { + threadId, + turns: snapshot.turns, + }; + }, + ); + + const rollbackThread: OpenCode2AdapterShape["rollbackThread"] = Effect.fn( + "opencode2.rollbackThread", + )(function* (threadId, numTurns) { + const context = yield* ensureSessionContext(threadId); + const snapshot = yield* readThreadSnapshot(context); + const targetIndex = Math.max(0, snapshot.turns.length - numTurns); + const target = snapshot.turns[targetIndex]; + if (!target) { + return { threadId, turns: snapshot.turns }; + } + + const entries = snapshot.messages; + const targetMessageIndex = entries.findIndex((entry) => entry.id === (target.id as string)); + if (targetMessageIndex < 0) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.fork", + detail: "The OpenCode 2 rewind boundary is no longer available.", + }); + } + const firstRemovedMessage = + entries.slice(0, targetMessageIndex + 1).findLast((entry) => entry.type === "user") ?? + entries[targetMessageIndex]!; + const firstRemovedMessageId = firstRemovedMessage.id; + + // Fork only the retained conversation so T3 alone decides whether + // filesystem changes survive; the fork id becomes the cursor. + const fork = yield* context.client.session + .fork({ + sessionID: toSessionId(context.openCodeSessionId), + boundary: { + type: "before", + messageID: firstRemovedMessageId, + }, + }) + .pipe(Effect.mapError(toRequestError("session.fork", "OpenCode 2 session fork failed."))); + const forkedSessionId = fork.id; + const forkSnapshot = yield* readThreadSnapshot({ + ...context, + openCodeSessionId: forkedSessionId, + }); + if (forkSnapshot.messages.length !== entries.indexOf(firstRemovedMessage)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.fork", + detail: "OpenCode 2 did not preserve the requested rewind boundary.", + }); + } + // Fork copies the parent's rules; re-assert them anyway in case the + // mode changed since (empty clears them when the thread is full-access). + if (context.appliedRulesMode) { + const ruleset = buildOpenCode2SessionRules({ + runtimeMode: context.appliedRulesMode, + ownMcpServerName: context.mcpServerName, + }); + yield* context.client.permission + .rules({ sessionID: forkedSessionId, permissions: ruleset }) + .pipe( + Effect.mapError( + toRequestError("permission.rules", "Failed to set rules on the forked session."), + ), + ); + } + yield* closePendingRequests(context, { type: "session.fork" }); + context.openCodeSessionId = forkedSessionId; + context.relatedSessionIds.clear(); + context.relatedSessionIds.add(forkedSessionId); + context.toolNamesById.clear(); + context.turnUsage = undefined; + context.activeTurnId = undefined; + context.interruptedTurnId = undefined; + context.reconcileIdleStatus = false; + context.awaitingBusyAfterInterruption = false; + context.pendingIdleReconciliation = undefined; + context.session = { + ...context.session, + resumeCursor: { schemaVersion: OPENCODE2_RESUME_VERSION, sessionId: forkedSessionId }, + updatedAt: yield* nowIso, + }; + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "thread.started", + payload: { providerThreadId: forkedSessionId }, + }); + return { + threadId, + turns: forkSnapshot.turns, + }; + }); + + const stopAllSessions = () => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + yield* Effect.forEach(contexts, (context) => Effect.ignoreCause(stopContext(context)), { + concurrency: "unbounded", + discard: true, + }); + }); + + const stopAll: OpenCode2AdapterShape["stopAll"] = () => stopAllSessions(); + + // Layer-level finalizer: when the adapter layer shuts down, stop every + // session. Closing each `sessionScope` interrupts the event pump and + // reconciliation fibers, deregisters the thread-scoped MCP server, and + // best-effort-interrupts the remote session. Consumers that can't reason + // about Effect scopes therefore cannot leak remote sessions by forgetting + // to call `stopAll`. + yield* Effect.addFinalizer(() => + stopAllSessions().pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + supportsConversationRollback: true, + }, + startSession, + sendTurn, + compaction: { type: "native", start: compactThread }, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCode2AdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts new file mode 100644 index 000000000000..3def12721865 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -0,0 +1,246 @@ +import { + type ModelCapabilities, + type OpenCode2Settings, + type ServerProviderModel, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { + buildServerProvider, + COMPACT_SLASH_COMMAND, + nonEmptyTrimmed, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + OpenCode2Runtime, + OpenCode2RuntimeError, + type OpenCode2Inventory, +} from "../opencode2Runtime.ts"; + +const OPENCODE2_PRESENTATION = { + displayName: "OpenCode 2", + showInteractionModeToggle: false, +} as const; +const OPENCODE2_PROBE_TIMEOUT = "8 seconds"; + +const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ], +}); + +function flattenOpenCode2Models(inventory: OpenCode2Inventory): ReadonlyArray { + return inventory.models.flatMap((model) => { + const name = nonEmptyTrimmed(model.name); + if (!name) return []; + const subProvider = nonEmptyTrimmed(model.providerId); + return [ + { + slug: `${model.providerId}/${model.id}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function openCode2SkillsToServerProviderSkills( + input: OpenCode2Inventory["skills"] | undefined, +): ReadonlyArray { + const skills: Array = []; + for (const skill of input ?? []) { + const name = nonEmptyTrimmed(skill.name); + const path = nonEmptyTrimmed(skill.location); + if (!name || !path) { + continue; + } + const description = nonEmptyTrimmed(skill.description ?? undefined); + skills.push({ + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }); + } + return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export const makePendingOpenCode2Provider = ( + openCode2Settings: OpenCode2Settings, +): Effect.Effect => + Effect.gen(function* () { + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: openCode2Settings.enabled, + checkedAt: DateTime.formatIso(yield* DateTime.now), + models: providerModelsFromSettings( + [], + openCode2Settings.customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 provider status has not been checked in this session yet.", + }, + }); + }); + +/** + * Probe the OpenCode 2 server and load inventory from the same connection + * chat uses. Health comes from the connected server (background service or + * external URL); no spawned process, no CLI version probe, and no + * authenticated session is created as a probe side effect. + */ +export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderStatus")(function* ( + openCode2Settings: OpenCode2Settings, + cwd: string, +): Effect.fn.Return { + const openCode2Runtime = yield* OpenCode2Runtime; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = openCode2Settings.customModels; + const isExternalServer = openCode2Settings.serverUrl.trim().length > 0; + + // `connect` and `loadInventory` already fail with an `OpenCode2RuntimeError` + // carrying a human-readable `detail`, so the probe surfaces that directly + // instead of re-wrapping the failure to recover the message. + const fallback = (detail: string) => + buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: openCode2Settings.enabled, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE2_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: detail, + }, + }); + + if (!openCode2Settings.enabled) { + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE2_MODEL_CAPABILITIES), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 is disabled in T3 Code settings.", + }, + }); + } + + const connectionResult = yield* openCode2Runtime + .connect({ + serverUrl: openCode2Settings.serverUrl, + ...(openCode2Settings.serverPassword + ? { serverPassword: openCode2Settings.serverPassword } + : {}), + // The probe is the first thing a fresh install hits; without the + // configured binary it would start the SDK's default `opencode` + // instead of `opencode2` and report the provider unavailable. + binaryPath: openCode2Settings.binaryPath, + }) + .pipe( + Effect.timeoutOrElse({ + duration: OPENCODE2_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCode2RuntimeError({ + operation: "connect", + detail: "OpenCode 2 connection probe timed out.", + }), + ), + }), + Effect.result, + ); + if (Result.isFailure(connectionResult)) { + return fallback(connectionResult.failure.detail); + } + const connection = connectionResult.success; + + const inventoryResult = yield* openCode2Runtime + .loadInventory({ client: connection.client, directory: cwd }) + .pipe( + Effect.timeoutOrElse({ + duration: OPENCODE2_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCode2RuntimeError({ + operation: "inventory", + detail: "OpenCode 2 inventory loading timed out.", + }), + ), + }), + Effect.result, + ); + if (Result.isFailure(inventoryResult)) { + return fallback(inventoryResult.failure.detail); + } + const inventory = inventoryResult.success; + + const models = providerModelsFromSettings( + flattenOpenCode2Models(inventory), + customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ); + const skills = openCode2SkillsToServerProviderSkills(inventory.skills); + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + slashCommands: [COMPACT_SLASH_COMMAND], + probe: { + installed: true, + version: connection.version, + status: models.length > 0 ? "ready" : "warning", + auth: { + status: models.length > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + models.length > 0 + ? `${models.length} model${models.length === 1 ? "" : "s"} available on ${isExternalServer ? "the configured OpenCode 2 server" : "the OpenCode 2 background service"}.` + : isExternalServer + ? "Connected to the configured OpenCode 2 server, but it reported no models." + : "OpenCode 2 is running, but it reported no models.", + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 25dafa5ba040..58eed7f5a3d4 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -56,6 +56,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; import * as CodexResetCredit from "./codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -447,6 +448,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // 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 infraLayer2 = OpenCode2Runtime.OpenCode2RuntimeLive.pipe( + Layer.provideMerge(NodeServices.layer), + ); const testLayer = AntigravityInstallation.layer.pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -454,6 +458,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }), ), Layer.provideMerge(infraLayer), + Layer.provideMerge(infraLayer2), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..4515da7902e8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -40,6 +40,7 @@ import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; import * as CodexResetCredit from "./codexResetCredit.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { @@ -2306,6 +2307,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.OpenCode2RuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -2405,6 +2407,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.OpenCode2RuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { if (command._tag !== "StandardCommand") return spawner.spawn(command); @@ -2521,6 +2524,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.OpenCode2RuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -2584,6 +2588,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCode2Runtime.OpenCode2RuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { @@ -2630,6 +2635,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 a8e6caf95aa7..b8a9629f53df 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -103,7 +103,12 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); - if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { + if ( + !isAntigravity && + !isCodex && + provider.driver !== ProviderDriverKind.make("opencode") && + provider.driver !== ProviderDriverKind.make("opencode2") + ) { return true; } @@ -127,7 +132,8 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => }; const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => - provider.driver === ProviderDriverKind.make("opencode") && + (provider.driver === ProviderDriverKind.make("opencode") || + provider.driver === ProviderDriverKind.make("opencode2")) && shouldRetainMissingProviderModels(provider); const mergeProviderModels = ( diff --git a/apps/server/src/provider/Services/OpenCode2Adapter.ts b/apps/server/src/provider/Services/OpenCode2Adapter.ts new file mode 100644 index 000000000000..840245b5cf18 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCode2Adapter.ts @@ -0,0 +1,16 @@ +/** + * OpenCode2Adapter — shape type for the OpenCode 2 provider adapter. + * + * Naming anchor mirroring {@link ./OpenCodeAdapter.ts} for the v2 driver + * bundle. The adapter talks to the machine's OpenCode 2 background service + * (or an explicit external server) through `@opencode/client/effect`. + * + * @module OpenCode2Adapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenCode2AdapterShape — per-instance OpenCode 2 adapter contract. + */ +export interface OpenCode2AdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..e2fdca1d6e90 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ 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 { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenCode2Driver, type OpenCode2DriverEnv } from "./Drivers/OpenCode2Driver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -39,6 +40,7 @@ export type BuiltInDriversEnv = | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv + | OpenCode2DriverEnv | AntigravityDriverEnv; /** @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray, + action: string, + resource = "*", +) => { + const matching = rules.filter( + (rule) => + // `*` matches zero or more characters in both fields. + new RegExp(`^${rule.action.replaceAll("*", ".*")}$`).test(action) && + new RegExp(`^${rule.resource.replaceAll("*", ".*")}$`).test(resource), + ); + return matching.length > 0 ? matching[matching.length - 1] : undefined; +}; + +const effectFor = ( + rules: ReturnType, + action: string, + resource = "*", +): "allow" | "deny" | "ask" | undefined => lastMatchingRule(rules, action, resource)?.effect; + +/** Session rules for a mode, with an optional own registration name. */ +const rulesFor = ( + runtimeMode: "full-access" | "approval-required" | "auto-accept-edits" | "auto", + ownMcpServerName?: string, +) => buildOpenCode2SessionRules({ runtimeMode, ownMcpServerName }); + +describe("buildOpenCode2SessionRules", () => { + it("grants full access freely while keeping cross-thread MCP isolation", () => { + // Full access skips approvals for this thread's own work, but must not let + // it act as another thread: the per-thread `t3-code-*` registrations carry + // the target thread's credential and are visible to every session. + const own = "t3-code-25e99ad6-4c39-49e3-8446-f6b58d7974ff"; + const rules = rulesFor("full-access", own); + NodeAssert.deepEqual(effectFor(rules, "shell"), undefined); + NodeAssert.deepEqual(effectFor(rules, "edit"), undefined); + NodeAssert.deepEqual( + effectFor(rules, "t3-code-00000000-0000-0000-0000-000000000000_list_thread_pull_requests"), + "deny", + ); + NodeAssert.deepEqual(effectFor(rules, `${own}_list_thread_pull_requests`), "allow"); + // Full access stays prompt-free: no catch-all ask, own tools auto-allowed. + NodeAssert.equal( + rules.some((rule) => rule.action === "*" && rule.resource === "*" && rule.effect === "ask"), + false, + ); + }); + + it("isolates foreign registrations that nest a customized name", () => { + // Registrations nest inside the reserved namespace, so one constant deny + // covers them; a customized `mcpServerName` must not escape isolation. + const own = openCode2McpServerName("corp", "25e99ad6-4c39-49e3-8446-f6b58d7974ff"); + for (const mode of ["full-access", "approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode, own); + // Another thread on the same instance: denied even though visible. + NodeAssert.deepEqual( + effectFor( + rules, + `${openCode2McpServerName("corp", "00000000-0000-0000-0000-000000000000")}_list_thread_pull_requests`, + ), + "deny", + ); + NodeAssert.deepEqual( + effectFor(rules, `${own}_list_thread_pull_requests`), + mode === "full-access" ? "allow" : "ask", + ); + } + }); + + it("isolates a colocated instance that configured a different name", () => { + // Regression: the deny was derived from this instance's own base, so with + // separate instances (`corp` vs `work`) on one server neither denied the + // other's registrations and their tools stayed callable cross-thread. + const other = openCode2McpServerName("work", "tB"); + for (const mode of ["full-access", "approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode, openCode2McpServerName("corp", "tA")); + NodeAssert.deepEqual(effectFor(rules, `${other}_list_thread_pull_requests`), "deny"); + NodeAssert.deepEqual(effectFor(rules, "t3-code-work-tB_list_thread_pull_requests"), "deny"); + } + }); + + it("keeps the reserved namespace regardless of the configured name", () => { + // The sanitizer rewrites the configured name too (`corp.io` → `corp_io`), + // and both the registration and the rules must agree on the result. + const own = openCode2McpServerName("corp.io", "t1"); + NodeAssert.equal(own, "t3-code-corp_io-t1"); + NodeAssert.ok(own.startsWith(`${OPENCODE2_MCP_NAMESPACE}-`)); + + const rules = rulesFor("full-access", own); + NodeAssert.deepEqual(effectFor(rules, `${own}_list_thread_pull_requests`), "allow"); + NodeAssert.deepEqual(effectFor(rules, "t3-code-corp.io-t1_list_thread_pull_requests"), "deny"); + }); + + it("keeps the cross-thread deny in every mode", () => { + const own = "t3-code-25e99ad6-4c39-49e3-8446-f6b58d7974ff"; + for (const mode of ["full-access", "approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode, own); + // Another thread's registration: denied even though visible. + NodeAssert.deepEqual( + effectFor(rules, "t3-code-00000000-0000-0000-0000-000000000000_list_thread_pull_requests"), + "deny", + ); + // This thread's own tools stay callable in every mode: auto-allowed + // under full access, surfaced as an approval otherwise. + const ownEffect = mode === "full-access" ? "allow" : "ask"; + NodeAssert.deepEqual(effectFor(rules, `${own}_list_thread_pull_requests`), ownEffect); + // Bare prefix match without the tool suffix must not over-allow. + NodeAssert.deepEqual( + effectFor(rules, `t3-code-25e99ad6-4c39-49e3-8446-f6b58d7974ffX`), + "deny", + ); + } + }); + + it("asks for everything in supervised mode", () => { + const rules = rulesFor("approval-required"); + NodeAssert.deepEqual(effectFor(rules, "shell"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "edit"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "webfetch"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "mcp_tool", "*"), "ask"); + }); + + it("denies foreign t3-code tools but keeps the thread's own callable", () => { + // OpenCode checks MCP tool calls as `_`. + const own = "t3-code-25e99ad6-4c39-49e3-8446-f6b58d7974ff"; + for (const mode of ["approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode, own); + // Another thread's registration: denied even though visible. + NodeAssert.deepEqual( + effectFor(rules, "t3-code-00000000-0000-0000-0000-000000000000_list_thread_pull_requests"), + "deny", + ); + // This thread's own tools stay callable (ask → surfaced as approval). + NodeAssert.deepEqual(effectFor(rules, `${own}_list_thread_pull_requests`), "ask"); + NodeAssert.deepEqual(effectFor(rules, `${own}_preview_open`), "ask"); + // Bare prefix match without the tool suffix must not over-allow. + NodeAssert.deepEqual( + effectFor(rules, `t3-code-25e99ad6-4c39-49e3-8446-f6b58d7974ffX`), + "deny", + ); + } + }); + + it("falls back to denying all t3-code tools when no own server name is given", () => { + const rules = rulesFor("approval-required"); + NodeAssert.deepEqual(effectFor(rules, "t3-code-25e99ad6_list_thread_pull_requests"), "deny"); + }); + + it("auto-allows edits only in auto-accept-edits mode", () => { + const autoEdits = rulesFor("auto-accept-edits"); + NodeAssert.deepEqual(effectFor(autoEdits, "edit"), "allow"); + const supervised = rulesFor("approval-required"); + NodeAssert.deepEqual(effectFor(supervised, "edit"), "ask"); + // `auto` falls back to supervised for OpenCode (documented parity). + const auto = rulesFor("auto"); + NodeAssert.deepEqual(effectFor(auto, "edit"), "ask"); + }); + + it("keeps read-only discovery frictionless in every restricted mode", () => { + for (const mode of ["approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode); + NodeAssert.deepEqual(effectFor(rules, "read"), "allow"); + NodeAssert.deepEqual(effectFor(rules, "glob"), "allow"); + NodeAssert.deepEqual(effectFor(rules, "grep"), "allow"); + NodeAssert.deepEqual(effectFor(rules, "subagent"), "allow"); + NodeAssert.deepEqual(effectFor(rules, "question"), "allow"); + } + }); + + it("keeps .env reads behind an ask with an example-file exception", () => { + const rules = rulesFor("approval-required"); + NodeAssert.deepEqual(effectFor(rules, "read", ".env"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "read", "foo.env"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "read", "foo.env.local"), "ask"); + NodeAssert.deepEqual(effectFor(rules, "read", "foo.env.example"), "allow"); + NodeAssert.deepEqual(effectFor(rules, "read", "src/main.ts"), "allow"); + }); + + it("starts with a catch-all ask so unlisted actions cannot slip through", () => { + for (const mode of ["approval-required", "auto-accept-edits", "auto"] as const) { + const rules = rulesFor(mode); + NodeAssert.deepEqual(rules[0], { action: "*", resource: "*", effect: "ask" }); + NodeAssert.deepEqual(effectFor(rules, "shell", "rm -rf /"), "ask"); + // Session rules are evaluated after agent rules with last-match-wins, + // so the server asks even when agent config allows the action. + } + }); + + it("covers every documented v2 core action (no v1 leftovers)", () => { + const rules = rulesFor("approval-required"); + const actions = new Set(rules.map((rule) => rule.action)); + for (const action of ["read", "edit", "glob", "grep", "question", "subagent"]) { + NodeAssert.deepEqual(actions.has(action), true); + } + // Everything else is covered by the `*` catch-all (verified above). + // v1 `bash`/`task` do not exist in v2 and must not appear. + NodeAssert.deepEqual(actions.has("bash"), false); + NodeAssert.deepEqual(actions.has("task"), false); + }); +}); diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts new file mode 100644 index 000000000000..42e23995a01b --- /dev/null +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -0,0 +1,154 @@ +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"; +import * as Ref from "effect/Ref"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + OpenCode2Runtime, + OpenCode2RuntimeLive, + OPENCODE2_MCP_NAMESPACE, + openCode2McpServerBase, + openCode2McpServerName, + openCode2ServiceCommand, + parseOpenCode2ModelSlug, + withOpenCode2Variant, +} from "./opencode2Runtime.ts"; + +const HEALTH_BODY = { healthy: true, version: "2.0.3", pid: 1234 }; + +/** + * Records every request URL while answering the health probe. Recording the + * request is the point: the external-server path used to normalize the + * configured URL with `url.parse(...).toString()`, which evaluates to the + * string "[object Object]" — the probe then requested "[object Object]/api/health" + * and every external server looked unreachable. Asserting on the runtime's own + * arguments would not have caught that. + */ +const makeTestLayer = (requestedUrls: Ref.Ref>) => + OpenCode2RuntimeLive.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.update(requestedUrls, (urls) => [...urls, String(request.url)]).pipe( + Effect.as(HttpClientResponse.fromWeb(request, Response.json(HEALTH_BODY))), + ), + ), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + +const connectWithRequestLog = (serverUrl: string) => + Effect.gen(function* () { + const requestedUrls = yield* Ref.make>([]); + const exit = yield* OpenCode2Runtime.pipe( + Effect.flatMap((runtime) => Effect.exit(runtime.connect({ serverUrl }))), + Effect.provide(makeTestLayer(requestedUrls)), + ); + return { exit, requestedUrls: yield* Ref.get(requestedUrls) }; + }); + +it.effect("probes an external server at its configured URL", () => + Effect.gen(function* () { + const { exit, requestedUrls } = yield* connectWithRequestLog("http://127.0.0.1:49374"); + + NodeAssert.equal(exit._tag, "Success"); + if (exit._tag !== "Success") return; + NodeAssert.equal(exit.value.url, "http://127.0.0.1:49374"); + NodeAssert.equal(exit.value.version, "2.0.3"); + NodeAssert.deepEqual(requestedUrls, ["http://127.0.0.1:49374/api/health"]); + }), +); + +it.effect("keeps a trailing slash from corrupting the probe URL", () => + Effect.gen(function* () { + const { requestedUrls } = yield* connectWithRequestLog("http://127.0.0.1:49374/"); + + NodeAssert.equal(requestedUrls.length, 1); + NodeAssert.ok(requestedUrls[0]?.includes("127.0.0.1:49374/api/health")); + NodeAssert.ok(!requestedUrls[0]?.includes("[object Object]")); + }), +); + +it("folds the composer variant selection into the model ref", () => { + // v2's `session.prompt` has no variant field, so the Reasoning selection + // must ride inside the model ref (`provider/model#variant`); without this + // the choice was silently dropped. + const base = parseOpenCode2ModelSlug("openai/gpt-6-astra"); + NodeAssert.equal(base?.variant, undefined); + + const withHigh = withOpenCode2Variant(base, "high"); + NodeAssert.equal(withHigh?.providerID, "openai"); + NodeAssert.equal(withHigh?.id, "gpt-6-astra"); + NodeAssert.equal(withHigh?.variant, "high"); +}); + +it("ignores an absent or blank variant, and degrades a bad one to no variant", () => { + const base = parseOpenCode2ModelSlug("openai/gpt-6-astra"); + + NodeAssert.equal(withOpenCode2Variant(base, undefined)?.variant, undefined); + NodeAssert.equal(withOpenCode2Variant(base, "")?.variant, undefined); + NodeAssert.equal(withOpenCode2Variant(base, " ")?.variant, undefined); + // A variant containing the ref separator cannot be encoded, so the model + // must survive while only the variant is dropped. + const bad = withOpenCode2Variant(base, "hi#gh"); + NodeAssert.equal(bad?.providerID, "openai"); + NodeAssert.equal(bad?.id, "gpt-6-astra"); + NodeAssert.equal(bad?.variant, undefined); +}); + +it("leaves an unparseable model selection alone", () => { + NodeAssert.equal(withOpenCode2Variant(undefined, "high"), undefined); +}); + +it("starts the background service with the configured binary path", () => { + // The SDK default is `opencode serve --service`, but the v2 binary is + // `opencode2`; a machine with only v2 installed could never pass the 2.x + // version gate through the default. + NodeAssert.deepEqual(openCode2ServiceCommand("/opt/opencode2"), [ + "/opt/opencode2", + "serve", + "--service", + ]); +}); + +it("defers to the SDK default command when no binary path is configured", () => { + NodeAssert.equal(openCode2ServiceCommand(undefined), undefined); + NodeAssert.equal(openCode2ServiceCommand(""), undefined); + NodeAssert.equal(openCode2ServiceCommand(" "), undefined); +}); + +it("nests every configured MCP name inside the reserved namespace", () => { + // Isolation rules match the namespace prefix, so a configured name must not + // replace it — otherwise a second instance could escape the deny. + NodeAssert.equal(openCode2McpServerBase(undefined), OPENCODE2_MCP_NAMESPACE); + NodeAssert.equal(openCode2McpServerBase(" "), OPENCODE2_MCP_NAMESPACE); + NodeAssert.equal(openCode2McpServerBase("corp"), `${OPENCODE2_MCP_NAMESPACE}-corp`); + // The sanitizer rewrites the configured name too, and both the registration + // and the rules must agree on the result. + NodeAssert.equal(openCode2McpServerBase("corp.io"), `${OPENCODE2_MCP_NAMESPACE}-corp_io`); +}); + +it("keeps per-thread names valid and predictable, including the hashed path", () => { + const threadId = "1df144f5-a78f-433d-a7d2-6c58ef966196"; + const name = openCode2McpServerName("corp.io", threadId); + NodeAssert.equal(name, `${OPENCODE2_MCP_NAMESPACE}-corp_io-${threadId}`); + NodeAssert.ok(/^[a-zA-Z0-9_-]+$/.test(name)); + NodeAssert.ok(name.startsWith(`${openCode2McpServerBase("corp.io")}-`)); + + // Beyond the length cap the name is hashed; the sanitized namespace prefix + // must still keep the result a valid MCP name in the reserved namespace + // (regression: the hashed path kept `corp.io` and emitted an invalid name). + const long = openCode2McpServerName("corp.io", "x".repeat(200)); + NodeAssert.ok(long.length <= 96, `expected <= 96 chars, got ${long.length}`); + NodeAssert.ok(/^[a-zA-Z0-9_-]+$/.test(long), `invalid MCP name: ${long}`); + NodeAssert.ok( + long.startsWith(`${OPENCODE2_MCP_NAMESPACE}-corp_io-`), + `expected reserved namespace, got: ${long}`, + ); +}); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts new file mode 100644 index 000000000000..93005fc02ba4 --- /dev/null +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -0,0 +1,464 @@ +import type { ChatAttachment, RuntimeMode } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as P from "effect/Predicate"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Model, OpenCode, Permission } from "@opencode/client/effect"; +import { Service as OpenCodeLocalService } from "@opencode/client/effect/service"; +import type { OpenCodeClient } from "@opencode/client/effect"; + +/** + * OpenCode 2 runtime: connection management for the machine's background + * service (or an explicit external server), plus inventory loading and the + * runtime-mode → session permission rules translation. + * + * Unlike the v1 runtime there is no process management here: v2's + * architecture is one shared background server owning sessions, plugins, + * permissions, and durable state. `Service.ensure` starts it when missing. + */ +export interface OpenCode2Connection { + readonly client: OpenCodeClient; + readonly url: string; + readonly external: boolean; + /** Server version from `health.get`, resolved at connect time. */ + readonly version: string; +} + +const OPENCODE2_RUNTIME_ERROR_TAG = "OpenCode2RuntimeError"; +export class OpenCode2RuntimeError extends Data.TaggedError(OPENCODE2_RUNTIME_ERROR_TAG)<{ + readonly operation: string; + readonly cause?: unknown; + readonly detail: string; +}> { + static readonly is = (u: unknown): u is OpenCode2RuntimeError => + P.isTagged(u, OPENCODE2_RUNTIME_ERROR_TAG); +} + +const OPENCODE2_CONNECT_TIMEOUT = "10 seconds"; + +/** + * v2 session rules for a T3 runtime mode. Session rules are evaluated after + * agent rules with last-match-wins, server-enforced, so they cannot be + * overridden by user config, agent definitions, or saved approvals — this is + * what makes supervised modes trustworthy on a shared background service. + * + * Cross-thread isolation: per-thread-named registrations are visible to every + * session in the directory, and each carries its target thread's credential, + * so a session must never call another thread's. Every T3 registration lives + * under the reserved `OPENCODE2_MCP_NAMESPACE`, so one constant deny covers all + * threads *and* all colocated instances — a per-instance pattern could not, + * since neither adapter knows the other's configured name. This holds in every + * mode including `full-access`: skipping approvals is about this thread's own + * work, not about letting it act as another thread. + */ +export function buildOpenCode2SessionRules(input: { + readonly runtimeMode: RuntimeMode; + /** This thread's own registration, allowed within the reserved namespace. */ + readonly ownMcpServerName?: string | undefined; +}): Permission.Ruleset { + // Everything T3 registers, from any instance or thread, is denied by + // default. The own-server rule is emitted after it and wins by last-match. + const foreignDeny: Permission.Ruleset = [ + { action: `${OPENCODE2_MCP_NAMESPACE}-*`, resource: "*", effect: "deny" }, + ]; + // Full access stays prompt-free, so the thread's own tools are auto-allowed + // rather than surfaced as an approval — but the cross-thread deny remains. + const ownRule = (effect: "ask" | "allow"): Permission.Ruleset => + input.ownMcpServerName + ? [{ action: `${input.ownMcpServerName}_*`, resource: "*", effect }] + : []; + + if (input.runtimeMode === "full-access") { + return [...foreignDeny, ...ownRule("allow")]; + } + const editEffect = + input.runtimeMode === "auto-accept-edits" ? ("allow" as const) : ("ask" as const); + return [ + // Catch-all ask first; the specific rules below win as later matches. + { action: "*", resource: "*", effect: "ask" }, + ...foreignDeny, + ...ownRule("ask"), + // Read-only discovery stays frictionless. + { action: "read", resource: "*", effect: "allow" }, + { action: "glob", resource: "*", effect: "allow" }, + { action: "grep", resource: "*", effect: "allow" }, + { action: "question", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "allow" }, + // OpenCode's own default keeps .env files behind a prompt. + { action: "read", resource: "*.env", effect: "ask" }, + { action: "read", resource: "*.env.*", effect: "ask" }, + { action: "read", resource: "*.env.example", effect: "allow" }, + { action: "edit", resource: "*", effect: editEffect }, + ]; +} + +/** + * Reserved namespace holding every T3 MCP registration on an OpenCode server. + * Isolation rules match this prefix and nothing else; a user-configured + * `mcpServerName` nests inside it rather than replacing it, so instances that + * configure different names still deny each other's registrations. + */ +export const OPENCODE2_MCP_NAMESPACE = "t3-code"; +/** MCP names must stay short and filename-safe; hash beyond this length. */ +const OPENCODE2_MCP_NAME_MAX_LENGTH = 96; + +/** + * Sanitized MCP naming base, shared by the registration name and the isolation + * rules. Both must agree on this value: the sanitizer rewrites the configured + * name too (`corp.io` → `corp_io`), so rules derived from the raw setting + * would not match the registered server. + */ +export function openCode2McpServerBase(baseName: string | undefined): string { + const trimmed = baseName?.trim(); + const label = + trimmed && trimmed.length > 0 + ? `${OPENCODE2_MCP_NAMESPACE}-${trimmed}` + : OPENCODE2_MCP_NAMESPACE; + return label.replaceAll(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** + * Deterministic per-thread MCP server name on a shared OpenCode server. MCP + * names allow letters, digits, `_`, and `-`; the base is sanitized so the + * isolation rules can predict it, and the thread id is hashed beyond the + * length cap so two threads in one directory hold independent registrations. + */ +export function openCode2McpServerName(baseName: string | undefined, threadId: string): string { + const base = openCode2McpServerBase(baseName); + const name = `${base}-${threadId}`.replaceAll(/[^a-zA-Z0-9_-]/g, "_"); + if (name.length <= OPENCODE2_MCP_NAME_MAX_LENGTH) { + return name; + } + // FNV-1a of the full thread id keeps the name stable across restarts. The + // hash is hex, so the already-sanitized base keeps the result a valid name. + let hash = 0x811c9dc5; + for (let index = 0; index < threadId.length; index += 1) { + hash ^= threadId.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `${base}-${hash.toString(16).padStart(8, "0")}`; +} + +export interface OpenCode2Inventory { + readonly models: ReadonlyArray<{ + readonly id: string; + readonly providerId: string; + readonly name: string; + readonly contextWindow: number; + readonly maxOutputTokens: number; + readonly status?: string; + }>; + readonly skills: ReadonlyArray<{ + readonly id: string; + readonly name: string; + readonly description?: string | null; + readonly location: string; + }>; +} + +export interface OpenCode2RuntimeShape { + /** + * Connect to the OpenCode server used by this provider instance: the + * explicitly configured external `serverUrl`, or the machine's background + * service (discovered, or started when absent). Connections are plain HTTP + * against an externally-owned server; nothing is held open by T3. + */ + readonly connect: (input: { + readonly serverUrl?: string | null; + readonly serverPassword?: string; + /** Path to the v2 binary used to start the background service when none is registered. */ + readonly binaryPath?: string; + }) => Effect.Effect; + /** Load model/agent/skill inventory from the same connection chat uses. */ + readonly loadInventory: (input: { + readonly client: OpenCodeClient; + readonly directory: string; + }) => Effect.Effect; +} + +export class OpenCode2Runtime extends Context.Service()( + "t3/provider/opencode2Runtime", +) {} + +function ensureRuntimeError( + operation: OpenCode2RuntimeError["operation"], + detail: string, + cause: unknown, +): OpenCode2RuntimeError { + return OpenCode2RuntimeError.is(cause) + ? cause + : new OpenCode2RuntimeError({ operation, detail, cause }); +} + +const basicAuthHeader = (username: string, password: string): string => + `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`; + +const makeOpenCode2Runtime = Effect.gen(function* () { + // Acquire the platform services once here so the service's methods have no + // requirements channel — mirrors the v1 runtime layer providing `NetService`. + const httpClient = yield* HttpClient.HttpClient; + // The client package's local-service discovery reads the registration file + // through this FileSystem when connect() falls through to discover(). + const fileSystem = yield* FileSystem.FileSystem; + + const clientFor = ( + url: string, + headers: Record, + ): Effect.Effect => + OpenCode.make({ baseUrl: url }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.mapRequest(httpClient, (request) => + HttpClientRequest.setHeaders(request, headers), + ), + ), + ); + + /** `health.get` is the connection's liveness + version probe in v2. */ + const probeConnection = (client: OpenCodeClient) => + client.health.get().pipe( + Effect.timeout(OPENCODE2_CONNECT_TIMEOUT), + Effect.map((health) => health.version), + Effect.mapError((cause) => + ensureRuntimeError( + "connect", + "The OpenCode server did not respond to a health probe.", + cause, + ), + ), + ); + + const connectExternal = (url: string, password: string | undefined) => + Effect.gen(function* () { + const headers = password ? { authorization: basicAuthHeader("opencode", password) } : {}; + const client = yield* clientFor(url, headers); + const version = yield* probeConnection(client); + return { client, url, external: true, version } satisfies OpenCode2Connection; + }); + + const connectBackgroundService = (binaryPath: string | undefined) => + Effect.gen(function* () { + const discovered = yield* OpenCodeLocalService.discover().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => + ensureRuntimeError("connect", "Failed to read the local service registration.", cause), + ), + ); + if (discovered) { + const headers = discovered.auth + ? { authorization: basicAuthHeader(discovered.auth.username, discovered.auth.password) } + : {}; + const client = yield* clientFor(discovered.url, headers); + const version = yield* probeConnection(client); + return { + client, + url: discovered.url, + external: false, + version, + } satisfies OpenCode2Connection; + } + const command = openCode2ServiceCommand(binaryPath); + const endpoint = yield* OpenCodeLocalService.ensure({ + version: (version) => version.startsWith("2."), + ...(command ? { command } : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => + ensureRuntimeError( + "connect", + "No OpenCode background service is registered and starting one failed.", + cause, + ), + ), + ); + const headers = OpenCodeLocalService.headers(endpoint) ?? {}; + const client = yield* clientFor(endpoint.url, headers); + const version = yield* probeConnection(client); + return { + client, + url: endpoint.url, + external: false, + version, + } satisfies OpenCode2Connection; + }); + + const connect: OpenCode2RuntimeShape["connect"] = (input) => + Effect.gen(function* () { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl && serverUrl.length > 0) { + return yield* connectExternal( + serverUrl, + input.serverPassword && input.serverPassword.length > 0 + ? input.serverPassword + : undefined, + ); + } + return yield* connectBackgroundService(input.binaryPath); + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + const loadInventory: OpenCode2RuntimeShape["loadInventory"] = (input) => + Effect.gen(function* () { + const { client, directory } = input; + const location = { directory }; + + const modelPage = yield* client.model + .list({ location }) + .pipe( + Effect.mapError((cause) => + ensureRuntimeError("inventory", "Failed to load OpenCode models.", cause), + ), + ); + + const skillPage = yield* client.skill + .list({ location }) + .pipe( + Effect.mapError((cause) => + ensureRuntimeError("inventory", "Failed to load OpenCode skills.", cause), + ), + ); + + return { + models: modelPage.data.map((model) => ({ + id: model.id, + providerId: model.providerID, + name: model.name, + contextWindow: model.limit.context, + maxOutputTokens: model.limit.output, + status: model.status, + })), + skills: skillPage.data.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description ?? null, + location: skill.location, + })), + } satisfies OpenCode2Inventory; + }); + + return OpenCode2Runtime.of({ connect, loadInventory }); +}); + +export const OpenCode2RuntimeLive = Layer.effect(OpenCode2Runtime, makeOpenCode2Runtime).pipe( + // `HttpClient` (API calls) and `FileSystem` (local service registration file) + // both come from the Node platform layer. + Layer.provide(NodeServices.layer), +); + +export type { OpenCodeClient }; + +/** + * Parse a `provider/model#variant` selection into a v2 `Model.Ref`. Returns + * undefined for anything malformed; `Model.Ref.parse` throws on inputs the + * separator check alone would let through (e.g. `openai/#`), so the try/catch + * is load-bearing rather than defensive. + */ +export function parseOpenCode2ModelSlug( + slug: string | null | undefined, +): ReturnType | undefined { + if (typeof slug !== "string") { + return undefined; + } + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return undefined; + } + try { + return Model.Ref.parse(trimmed); + } catch { + return undefined; + } +} + +/** + * Service command used when T3 has to start the v2 background service itself. + * The SDK default is `opencode serve --service`, but the v2 binary is + * `opencode2` — on a machine with only v2 installed, going through the default + * would spawn a v1 binary (or nothing) and the 2.x version gate could never + * pass. Returns undefined so the caller can fall back to the SDK default. + */ +export function openCode2ServiceCommand( + binaryPath: string | undefined, +): ReadonlyArray | undefined { + const command = binaryPath?.trim(); + return command && command.length > 0 ? [command, "serve", "--service"] : undefined; +} + +/** + * Fold a composer `variant` selection (the Reasoning option) into a parsed + * model ref. v2's `session.prompt` has no variant field — the variant rides + * inside the model ref as `provider/model#variant` — so it has to be attached + * here. A variant that fails to parse yields the ref unchanged, so a bad + * selection degrades to the provider default instead of dropping the model. + */ +export function withOpenCode2Variant( + ref: ReturnType | undefined, + variant: string | null | undefined, +): ReturnType | undefined { + const trimmed = variant?.trim(); + if (!ref || !trimmed || trimmed.length === 0) { + return ref; + } + return parseOpenCode2ModelSlug(`${ref.providerID}/${ref.id}#${trimmed}`) ?? ref; +} + +/** + * v2 file attachments. Gating mirrors the v1 adapter's native-file rules + * (images, text, pdf under 20 MB) so providers without vision or large-file + * support do not receive parts they cannot process. + */ +export function toOpenCode2FileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): ReadonlyArray<{ + readonly type: "file"; + readonly mime: string; + readonly name: string; + readonly url: string; +}> { + const OPENCODE2_NATIVE_IMAGE_MIMES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + ]); + const OPENCODE2_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; + + const parts: Array<{ + readonly type: "file"; + readonly mime: string; + readonly name: string; + readonly url: string; + }> = []; + for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } + const normalized = attachment.mimeType.trim().toLowerCase(); + const isNative = + attachment.sizeBytes <= OPENCODE2_NATIVE_FILE_PART_MAX_BYTES && + (OPENCODE2_NATIVE_IMAGE_MIMES.has(normalized) || + normalized.startsWith("text/") || + normalized === "application/pdf"); + if (!isNative) continue; + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) continue; + parts.push({ + type: "file", + mime: attachment.mimeType, + name: attachment.name, + url: NodeURL.pathToFileURL(attachmentPath).href, + }); + } + return parts; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0aeecb3f7b8..5180a7875c5b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { ProviderUsageLimitsIngestionLive } from "./provider/Layers/ProviderUsageLimitsIngestion.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"; @@ -524,6 +525,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // `OpenCode2Driver.create()` yields `OpenCode2Runtime`; same reasoning as + // the v1 runtime above — one Live for all opencode2 consumers. + Layer.provideMerge(OpenCode2Runtime.OpenCode2RuntimeLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), Layer.provideMerge(RepositoryIdentityResolverLayerLive), diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts new file mode 100644 index 000000000000..90b0b13dc258 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -0,0 +1,129 @@ +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 Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { OpenCode2Settings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCode2Runtime, type OpenCode2RuntimeShape } from "../provider/opencode2Runtime.ts"; +import { makeOpenCode2TextGeneration } from "./OpenCode2TextGeneration.ts"; + +const decodeSettings = Schema.decodeSync(OpenCode2Settings); +const INSTANCE = ProviderInstanceId.make("opencode2"); + +/** + * The generation endpoint is stubbed at the runtime boundary so these tests + * pin the decode path rather than the server: `generate.text` returns the + * model's raw text, and `extractJsonObject` hands it on as a JSON *string*. + * `requestedModels` records the ref actually sent so variant forwarding is + * observable. + */ +const runtimeReturning = ( + text: () => string, + requestedModels?: Array>, +): OpenCode2RuntimeShape => ({ + connect: () => + Effect.succeed({ + client: { + generate: { + text: (input: { readonly model?: Record }) => + Effect.sync(() => { + requestedModels?.push(input.model ?? {}); + return { text: text() }; + }), + }, + }, + url: "http://stub", + external: false, + version: "2.0.3", + } as never), + loadInventory: () => Effect.die("not used"), +}); + +const makeTextGeneration = (text: () => string, requestedModels?: Array>) => + makeOpenCode2TextGeneration(decodeSettings({ enabled: true })).pipe( + Effect.provideService( + OpenCode2Runtime, + OpenCode2Runtime.of(runtimeReturning(text, requestedModels)), + ), + ); + +const generateTitle = ( + text: () => string, + message = "investigate the flaky CI job", + requestedModels?: Array>, +) => + Effect.gen(function* () { + const textGeneration = yield* makeTextGeneration(text, requestedModels); + return yield* textGeneration.generateThreadTitle({ + message, + cwd: process.cwd(), + modelSelection: createModelSelection(INSTANCE, "openai/gpt-6-astra"), + }); + }).pipe(Effect.provide(NodeServices.layer)); + +it.effect("forwards the selected reasoning variant into the model ref", () => + Effect.gen(function* () { + // Regression: only the base slug was parsed, so Low/High/Extra High were + // silently ignored and text generation used the provider default. + const requestedModels: Array> = []; + const textGeneration = yield* makeTextGeneration( + () => JSON.stringify({ title: "Fix flaky CI job" }), + requestedModels, + ); + yield* textGeneration.generateThreadTitle({ + message: "investigate the flaky CI job", + cwd: process.cwd(), + modelSelection: createModelSelection(INSTANCE, "openai/gpt-6-astra", [ + { id: "variant", value: "high" }, + ]), + }); + + NodeAssert.equal(requestedModels.length, 1); + NodeAssert.equal(requestedModels[0]?.providerID, "openai"); + NodeAssert.equal(requestedModels[0]?.id, "gpt-6-astra"); + NodeAssert.equal(requestedModels[0]?.variant, "high"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("decodes a plain JSON object returned by the model", () => + Effect.gen(function* () { + const result = yield* generateTitle(() => JSON.stringify({ title: "Fix flaky CI job" })); + NodeAssert.equal(result.title, "Fix flaky CI job"); + }), +); + +it.effect("decodes JSON the model wrapped in a code fence", () => + Effect.gen(function* () { + // Regression: the schema was applied to the extracted JSON *string* + // instead of the parsed object, so every call failed with + // "SchemaError: Expected object". + const result = yield* generateTitle( + () => "```json\n" + JSON.stringify({ title: "Fix flaky CI job" }) + "\n```", + ); + NodeAssert.equal(result.title, "Fix flaky CI job"); + }), +); + +it.effect("decodes JSON the model embedded in prose", () => + Effect.gen(function* () { + const result = yield* generateTitle( + () => `Sure! Here's a title:\n\n${JSON.stringify({ title: "Fix flaky CI job" })}`, + ); + NodeAssert.equal(result.title, "Fix flaky CI job"); + }), +); + +it.effect("fails with a TextGenerationError when the output is not JSON", () => + Effect.gen(function* () { + const exit = yield* Effect.exit(generateTitle(() => "no json here at all")); + NodeAssert.equal(exit._tag, "Failure"); + if (exit._tag !== "Failure") return; + const error = exit.cause.reasons + .map((reason) => (reason as { readonly error?: unknown }).error) + .find((candidate) => candidate !== undefined) as { readonly _tag?: string } | undefined; + NodeAssert.equal(error?._tag, "TextGenerationError"); + }), +); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts new file mode 100644 index 000000000000..39be57c05b76 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -0,0 +1,219 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + TextGenerationError, + type ModelSelection, + type OpenCode2Settings, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import * as OpenCode2Runtime from "../provider/opencode2Runtime.ts"; + +const OpenCode2TextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCode2TextGenerationOperation = typeof OpenCode2TextGenerationOperation.Type; + +/** + * v2's `generate.text` runs on the connected server with no session and no + * tool access (the endpoint is generation-only), so unlike the v1 helper + * there is no spawned server to own and no permission ruleset to inject — + * the deny-by-default guarantee comes from the endpoint itself. + */ +export const makeOpenCode2TextGeneration = (openCode2Settings: OpenCode2Settings) => + Effect.gen(function* () { + const openCode2Runtime = yield* OpenCode2Runtime.OpenCode2Runtime; + + const generate = (input: { + readonly operation: OpenCode2TextGenerationOperation; + readonly prompt: string; + readonly outputSchema: Schema.Codec; + readonly modelSelection: ModelSelection; + }) => + Effect.gen(function* () { + const parsedModel = OpenCode2Runtime.withOpenCode2Variant( + OpenCode2Runtime.parseOpenCode2ModelSlug(input.modelSelection.model), + getModelSelectionStringOptionValue(input.modelSelection, "variant"), + ); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + + const connection = yield* openCode2Runtime.connect({ + ...(openCode2Settings.serverUrl ? { serverUrl: openCode2Settings.serverUrl } : {}), + ...(openCode2Settings.serverPassword + ? { serverPassword: openCode2Settings.serverPassword } + : {}), + binaryPath: openCode2Settings.binaryPath, + }); + + const result = yield* connection.client.generate + .text({ + prompt: input.prompt, + model: parsedModel, + }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 generate request failed.", + cause, + }), + ), + ); + + const rawText = result.text.trim(); + if (rawText.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned empty output.", + }); + } + + // `extractJsonObject` returns the JSON *text*; decode it through + // `fromJsonString` so the output schema is applied to the parsed + // object rather than to a string (mirrors the other providers). + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(rawText)).pipe( + Effect.catchTag( + "SchemaError", + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned invalid structured output.", + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + OpenCode2RuntimeError: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause.detail, + cause, + }), + }), + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + (input) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* generate({ + operation: "generateCommitMessage", + 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"] = ( + input, + ) => + Effect.gen(function* () { + 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* generate({ + operation: "generatePrContent", + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = ( + input, + ) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* generate({ + operation: "generateBranchName", + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( + input, + ) => + Effect.gen(function* () { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + }); + const generated = yield* generate({ + operation: "generateThreadTitle", + 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 27fc28df7bf7..2ac93a597fca 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -10,7 +10,13 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as ThreadTitleLinks from "./ThreadTitleLinks.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/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 5ee3743a33ee..5d2191a18e77 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -91,7 +91,9 @@ export function shouldIncludeModelPickerOption(input: { if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && + (input.entry.driverKind === "opencode" || + input.entry.driverKind === "opencode2" || + input.entry.driverKind === "antigravity") && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 05e8d2217ff9..6e0a20ed7bf0 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -80,7 +80,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: props.model, options: selectedInstanceOptions, }) ?? - (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" + (activeEntry?.driverKind === "opencode" || + activeEntry?.driverKind === "opencode2" || + activeEntry?.driverKind === "antigravity" ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index da293c38ddc9..dcc508ac348d 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -144,7 +144,7 @@ function getSelectedTraits( ) { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const modelIsUnavailable = - provider === "opencode" && + (provider === "opencode" || provider === "opencode2") && !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider)); const descriptors = modelIsUnavailable ? buildUnavailableModelOptionDescriptors( diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c6837700cdfa..b259bf041b29 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -111,7 +111,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptInjectionState = "none", planModeEnabled, } = input; - if (provider === "opencode") { + if (provider === "opencode" || provider === "opencode2") { const normalizedModel = normalizeModelSlug(model, provider); const modelIsInCatalog = models.some((candidate) => candidate.slug === normalizedModel); if (!modelIsInCatalog) { diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..853040b8f7c3 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -13,6 +13,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, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 0cc88eb06384..1872e1807ba0 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -28,6 +28,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial, ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity") return options; + if (provider !== "opencode" && provider !== "opencode2" && provider !== "antigravity") { + return options; + } const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -308,7 +310,9 @@ export function resolveAppModelSelectionForInstance( } if ( resolutionOptions?.preserveUnavailableSelection && - (entry.driverKind === "opencode" || entry.driverKind === "antigravity") + (entry.driverKind === "opencode" || + entry.driverKind === "opencode2" || + entry.driverKind === "antigravity") ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 0882aef51d05..506e215c6e55 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,6 +148,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-6-astra"; @@ -173,6 +174,10 @@ 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.ts b/packages/contracts/src/providerRuntime.ts index af1baac74f9d..f7aab312cf22 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -28,6 +28,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("opencode2.sdk.event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1c53b36037ba..35d8d3882cbd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -866,6 +866,84 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const OpenCode2Settings = makeProviderSettingsSchema( + { + // Off by default: the driver talks to a shared background service, so + // users opt in explicitly from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + 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", + }, + }), + ), + serverUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server URL", + description: "Leave blank to connect to the machine's OpenCode background service.", + 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. Only used for a custom server URL.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + mcpServerName: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "MCP server name", + description: + 'Name T3 Code registers itself under on the OpenCode server. Defaults to "t3-code".', + providerSettingsForm: { + placeholder: "t3-code", + clearWhenEmpty: "omit", + }, + }), + ), + mcpEndpointUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "MCP endpoint URL", + description: + "URL OpenCode uses to reach this T3 Code server's /mcp. " + + "Defaults to the server's bound address; set it when OpenCode " + + "cannot reach that (remote OpenCode server, tailscale, tunnel).", + providerSettingsForm: { + placeholder: "http://100.x.y.z:PORT", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "serverUrl", "serverPassword", "mcpServerName", "mcpEndpointUrl"], + }, +); +export type OpenCode2Settings = typeof OpenCode2Settings.Type; + /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the @@ -1172,6 +1250,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({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -1346,6 +1425,16 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const OpenCode2SettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + serverUrl: Schema.optionalKey(TrimmedString), + serverPassword: Schema.optionalKey(TrimmedString), + mcpServerName: Schema.optionalKey(TrimmedString), + mcpEndpointUrl: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), @@ -1419,6 +1508,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + opencode2: Schema.optionalKey(OpenCode2SettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), ), diff --git a/patches/@opencode__client@2.0.3.patch b/patches/@opencode__client@2.0.3.patch new file mode 100644 index 000000000000..24de07a398b8 --- /dev/null +++ b/patches/@opencode__client@2.0.3.patch @@ -0,0 +1,18 @@ +diff --git a/dist/effect/index.js b/dist/effect/index.js +--- a/dist/effect/index.js ++++ b/dist/effect/index.js +@@ -1 +1 @@ +-export * from "./generated/index"; ++export * from "./generated/index.js"; +diff --git a/dist/effect/generated/client.js b/dist/effect/generated/client.js +--- a/dist/effect/generated/client.js ++++ b/dist/effect/generated/client.js +@@ -6 +6 @@ +-import { ClientApi } from "../../contract"; ++import { ClientApi } from "../../contract.js"; +diff --git a/dist/solid/data.js b/dist/solid/data.js +--- a/dist/solid/data.js ++++ b/dist/solid/data.js +@@ -8 +8 @@ +-import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise"; ++import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise/index.js"; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9446bf4e4dd5..6738356152ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 + '@opencode/client@2.0.3': ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -509,6 +510,12 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@opencode/client': + specifier: 2.0.3 + version: 2.0.3(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@opencode/schema': + specifier: 2.0.3 + version: 2.0.3 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) @@ -3697,6 +3704,23 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + '@opencode/client@2.0.3': + resolution: {integrity: sha512-b+LcpMI131fXnGqf+O+9Y33H4XmwAkQibfYvtpEOROftkTpkcMj3R7WW/wGl2VTBNTZECAk2Yd+TACTzzAwk0A==} + peerDependencies: + effect: 4.0.0-rc.112 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.3': + resolution: {integrity: sha512-DNHbkLDTuAyMsmVdnjuKulcfoDbllW2dxt4W19r2aJEYvswJpEO9168CSK6Sjbx1FOh/B9yL7SQgoDRMd4K5Vw==} + + '@opencode/schema@2.0.3': + resolution: {integrity: sha512-thPeBbqw4+SkZ/0LOuqfahSRGuWogRa/wwfIVyiQYsoE0vzLx8XUpHQ+FSUWkdz6llDtP0+ZsyBqXUMpXmPgbg==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -14528,6 +14552,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.3(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': + dependencies: + '@opencode/protocol': 2.0.3 + '@opencode/schema': 2.0.3 + optionalDependencies: + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + + '@opencode/protocol@2.0.3': + dependencies: + '@opencode/schema': 2.0.3 + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + + '@opencode/schema@2.0.3': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + '@oslojs/encoding@1.1.0': {} '@oxc-parser/binding-android-arm-eabi@0.147.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a4a6c3f6b069..0461d6c30563 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -162,6 +162,7 @@ patchedDependencies: "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch + "@opencode/client@2.0.3": patches/@opencode__client@2.0.3.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 01955554adf6..346454051f7c 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -473,6 +473,30 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2025 opencode"] } + }, + { + "name": "@opencode/client", + "sourceUrl": "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/anomalyco/opencode", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 opencode"] + } + }, + { + "name": "@opencode/protocol", + "sourceUrl": "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/anomalyco/opencode", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 opencode"] + } + }, + { + "name": "@opencode/schema", + "sourceUrl": "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/anomalyco/opencode", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2025 opencode"] + } } ] } From e66787e58e4980b5ee56be9786b1cb2df9f3343f Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 15:56:30 +0300 Subject: [PATCH 02/17] fix(server): harden OpenCode 2 session and connection handling Address review findings on the OpenCode 2 driver: - gate every connection on a 2.x server version and skip a registered non-2.x background service so `ensure` replaces it - keep hashed MCP server names within the length cap when the configured base name is long - only deregister the thread MCP server from the context that registered it, so a lost startup race cannot strip the winner's tools - tear the session down when its root OpenCode session is deleted - mirror renames of the root session only, not subagent child sessions - report only server-advertised models in the provider probe message - inline the runtime service interface per the Effect service conventions Co-Authored-By: Claude Fable 5.1 --- .../src/provider/Layers/OpenCode2Adapter.ts | 43 +++++++-- .../src/provider/Layers/OpenCode2Provider.ts | 9 +- .../src/provider/opencode2Runtime.test.ts | 41 ++++++++- apps/server/src/provider/opencode2Runtime.ts | 91 ++++++++++++------- .../OpenCode2TextGeneration.test.ts | 4 +- 5 files changed, 136 insertions(+), 52 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 2d55389d87f3..e3514ecf463f 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -343,6 +343,12 @@ interface OpenCode2SessionContext { /** Runtime mode whose ruleset was last written to the session. */ appliedRulesMode: RuntimeMode | undefined; readonly mcpServerName: string; + /** + * Set once this context's `mcp.add` landed. The name is deterministic per + * thread, so a context that never registered (a lost startup race) must not + * remove the registration a concurrent winner relies on. + */ + mcpRegistered: boolean; cancellationTurnId: TurnId | undefined; interruptedTurnId: TurnId | undefined; reconcileIdleStatus: boolean; @@ -1322,12 +1328,15 @@ export function makeOpenCode2Adapter( } // Best-effort deregistration of this thread's MCP server; concurrent // threads keep their own uniquely named registrations. - yield* context.client.mcp - .remove({ - server: context.mcpServerName, - location: { directory: toDirectory(context.directory) }, - }) - .pipe(Effect.ignore); + if (context.mcpRegistered) { + context.mcpRegistered = false; + yield* context.client.mcp + .remove({ + server: context.mcpServerName, + location: { directory: toDirectory(context.directory) }, + }) + .pipe(Effect.ignore); + } yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore); }); @@ -1533,14 +1542,28 @@ export function makeOpenCode2Adapter( break; } case "session.deleted": { + if (event.data.sessionID === context.openCodeSessionId) { + // The root session is gone from the shared server, so no later + // event or turn can reach it; tear down like a server exit. + yield* emitUnexpectedExit( + context, + "The OpenCode 2 session was deleted on the server.", + ); + return; + } context.relatedSessionIds.delete(event.data.sessionID); break; } case "session.renamed": { const title = trimText(event.data.title); - // Mirror user renames, but not OpenCode's auto-generated - // placeholders — those would lock the thread onto them. - if (title && !isOpenCode2DefaultTitle(title)) { + // Mirror user renames of the root session, but not subagent child + // sessions or OpenCode's auto-generated placeholders — either + // would overwrite the thread name. + if ( + event.data.sessionID === context.openCodeSessionId && + title && + !isOpenCode2DefaultTitle(title) + ) { yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -2169,6 +2192,7 @@ export function makeOpenCode2Adapter( toRequestError("mcp.add", "Failed to register the T3 Code MCP server."), ), ); + context.mcpRegistered = true; }); // ── message listing ─────────────────────────────────────────── @@ -2431,6 +2455,7 @@ export function makeOpenCode2Adapter( activeTurnId: undefined, appliedRulesMode: input.runtimeMode, mcpServerName, + mcpRegistered: false, cancellationTurnId: undefined, interruptedTurnId: undefined, reconcileIdleStatus: false, diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts index 3def12721865..71546d12daa4 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -214,8 +214,9 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta } const inventory = inventoryResult.success; + const serverModels = flattenOpenCode2Models(inventory); const models = providerModelsFromSettings( - flattenOpenCode2Models(inventory), + serverModels, customModels, DEFAULT_OPENCODE2_MODEL_CAPABILITIES, ); @@ -235,9 +236,11 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta status: models.length > 0 ? "authenticated" : "unknown", type: "opencode", }, + // Readiness counts custom models too; the message reports only what + // the server itself advertises. message: - models.length > 0 - ? `${models.length} model${models.length === 1 ? "" : "s"} available on ${isExternalServer ? "the configured OpenCode 2 server" : "the OpenCode 2 background service"}.` + serverModels.length > 0 + ? `${serverModels.length} model${serverModels.length === 1 ? "" : "s"} available on ${isExternalServer ? "the configured OpenCode 2 server" : "the OpenCode 2 background service"}.` : isExternalServer ? "Connected to the configured OpenCode 2 server, but it reported no models." : "OpenCode 2 is running, but it reported no models.", diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts index 42e23995a01b..7cbba91c2baf 100644 --- a/apps/server/src/provider/opencode2Runtime.test.ts +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -9,6 +10,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { OpenCode2Runtime, + OpenCode2RuntimeError, OpenCode2RuntimeLive, OPENCODE2_MCP_NAMESPACE, openCode2McpServerBase, @@ -28,14 +30,17 @@ const HEALTH_BODY = { healthy: true, version: "2.0.3", pid: 1234 }; * and every external server looked unreachable. Asserting on the runtime's own * arguments would not have caught that. */ -const makeTestLayer = (requestedUrls: Ref.Ref>) => +const makeTestLayer = ( + requestedUrls: Ref.Ref>, + healthBody: Record = HEALTH_BODY, +) => OpenCode2RuntimeLive.pipe( Layer.provide( Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Ref.update(requestedUrls, (urls) => [...urls, String(request.url)]).pipe( - Effect.as(HttpClientResponse.fromWeb(request, Response.json(HEALTH_BODY))), + Effect.as(HttpClientResponse.fromWeb(request, Response.json(healthBody))), ), ), ), @@ -43,12 +48,12 @@ const makeTestLayer = (requestedUrls: Ref.Ref>) => Layer.provideMerge(NodeServices.layer), ); -const connectWithRequestLog = (serverUrl: string) => +const connectWithRequestLog = (serverUrl: string, healthBody?: Record) => Effect.gen(function* () { const requestedUrls = yield* Ref.make>([]); const exit = yield* OpenCode2Runtime.pipe( Effect.flatMap((runtime) => Effect.exit(runtime.connect({ serverUrl }))), - Effect.provide(makeTestLayer(requestedUrls)), + Effect.provide(makeTestLayer(requestedUrls, healthBody)), ); return { exit, requestedUrls: yield* Ref.get(requestedUrls) }; }); @@ -75,6 +80,24 @@ it.effect("keeps a trailing slash from corrupting the probe URL", () => }), ); +it.effect("rejects a healthy external server that is not a 2.x release", () => + Effect.gen(function* () { + // A v1 server answers the health probe too; adopting it would only fail + // later on the first v2-only call, with no hint about the version. + const { exit } = yield* connectWithRequestLog("http://127.0.0.1:49374", { + ...HEALTH_BODY, + version: "1.12.4", + }); + + NodeAssert.equal(exit._tag, "Failure"); + if (exit._tag !== "Failure") return; + const failure = Cause.squash(exit.cause); + NodeAssert.ok(OpenCode2RuntimeError.is(failure)); + NodeAssert.match(failure.detail, /version 1\.12\.4/); + NodeAssert.match(failure.detail, /2\.x/); + }), +); + it("folds the composer variant selection into the model ref", () => { // v2's `session.prompt` has no variant field, so the Reasoning selection // must ride inside the model ref (`provider/model#variant`); without this @@ -151,4 +174,14 @@ it("keeps per-thread names valid and predictable, including the hashed path", () long.startsWith(`${OPENCODE2_MCP_NAMESPACE}-corp_io-`), `expected reserved namespace, got: ${long}`, ); + + // A long configured base is capped too, and the hash still tells two + // instances apart whose names only differ beyond the cut. + const threadIdShort = "thread"; + const longBaseA = openCode2McpServerName(`${"a".repeat(120)}-one`, threadIdShort); + const longBaseB = openCode2McpServerName(`${"a".repeat(120)}-two`, threadIdShort); + NodeAssert.ok(longBaseA.length <= 96, `expected <= 96 chars, got ${longBaseA.length}`); + NodeAssert.ok(longBaseA.startsWith(`${OPENCODE2_MCP_NAMESPACE}-aaa`)); + NodeAssert.notEqual(longBaseA, longBaseB); + NodeAssert.equal(longBaseA, openCode2McpServerName(`${"a".repeat(120)}-one`, threadIdShort)); }); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 93005fc02ba4..bc360e3f79f3 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -42,6 +42,9 @@ export class OpenCode2RuntimeError extends Data.TaggedError(OPENCODE2_RUNTIME_ER const OPENCODE2_CONNECT_TIMEOUT = "10 seconds"; +/** Only 2.x servers speak the v2 API; the same gate drives service discovery. */ +const isOpenCode2Version = (version: string): boolean => version.startsWith("2."); + /** * v2 session rules for a T3 runtime mode. Session rules are evaluated after * agent rules with last-match-wins, server-enforced, so they cannot be @@ -135,14 +138,18 @@ export function openCode2McpServerName(baseName: string | undefined, threadId: s if (name.length <= OPENCODE2_MCP_NAME_MAX_LENGTH) { return name; } - // FNV-1a of the full thread id keeps the name stable across restarts. The - // hash is hex, so the already-sanitized base keeps the result a valid name. + // FNV-1a over base and thread id keeps the name stable across restarts and + // distinct when a long configured base is truncated. The hash is hex, so the + // already-sanitized base keeps the result a valid name. + const hashInput = `${base}-${threadId}`; let hash = 0x811c9dc5; - for (let index = 0; index < threadId.length; index += 1) { - hash ^= threadId.charCodeAt(index); + for (let index = 0; index < hashInput.length; index += 1) { + hash ^= hashInput.charCodeAt(index); hash = Math.imul(hash, 0x01000193) >>> 0; } - return `${base}-${hash.toString(16).padStart(8, "0")}`; + const suffix = hash.toString(16).padStart(8, "0"); + const prefixLength = OPENCODE2_MCP_NAME_MAX_LENGTH - suffix.length - 1; + return `${base.slice(0, prefixLength)}-${suffix}`; } export interface OpenCode2Inventory { @@ -162,29 +169,28 @@ export interface OpenCode2Inventory { }>; } -export interface OpenCode2RuntimeShape { - /** - * Connect to the OpenCode server used by this provider instance: the - * explicitly configured external `serverUrl`, or the machine's background - * service (discovered, or started when absent). Connections are plain HTTP - * against an externally-owned server; nothing is held open by T3. - */ - readonly connect: (input: { - readonly serverUrl?: string | null; - readonly serverPassword?: string; - /** Path to the v2 binary used to start the background service when none is registered. */ - readonly binaryPath?: string; - }) => Effect.Effect; - /** Load model/agent/skill inventory from the same connection chat uses. */ - readonly loadInventory: (input: { - readonly client: OpenCodeClient; - readonly directory: string; - }) => Effect.Effect; -} - -export class OpenCode2Runtime extends Context.Service()( - "t3/provider/opencode2Runtime", -) {} +export class OpenCode2Runtime extends Context.Service< + OpenCode2Runtime, + { + /** + * Connect to the OpenCode server used by this provider instance: the + * explicitly configured external `serverUrl`, or the machine's background + * service (discovered, or started when absent). Connections are plain HTTP + * against an externally-owned server; nothing is held open by T3. + */ + readonly connect: (input: { + readonly serverUrl?: string | null; + readonly serverPassword?: string; + /** Path to the v2 binary used to start the background service when none is registered. */ + readonly binaryPath?: string; + }) => Effect.Effect; + /** Load model/agent/skill inventory from the same connection chat uses. */ + readonly loadInventory: (input: { + readonly client: OpenCodeClient; + readonly directory: string; + }) => Effect.Effect; + } +>()("t3/provider/opencode2Runtime") {} function ensureRuntimeError( operation: OpenCode2RuntimeError["operation"], @@ -220,11 +226,14 @@ const makeOpenCode2Runtime = Effect.gen(function* () { ), ); - /** `health.get` is the connection's liveness + version probe in v2. */ + /** + * `health.get` is the connection's liveness + version probe in v2. A healthy + * v1 server answers it too, so the version is gated here: adopting one would + * only fail later, on the first v2-only call. + */ const probeConnection = (client: OpenCodeClient) => client.health.get().pipe( Effect.timeout(OPENCODE2_CONNECT_TIMEOUT), - Effect.map((health) => health.version), Effect.mapError((cause) => ensureRuntimeError( "connect", @@ -232,6 +241,16 @@ const makeOpenCode2Runtime = Effect.gen(function* () { cause, ), ), + Effect.flatMap((health) => + isOpenCode2Version(health.version) + ? Effect.succeed(health.version) + : Effect.fail( + new OpenCode2RuntimeError({ + operation: "connect", + detail: `The OpenCode server reports version ${health.version}, but OpenCode 2 requires a 2.x server.`, + }), + ), + ), ); const connectExternal = (url: string, password: string | undefined) => @@ -244,7 +263,11 @@ const makeOpenCode2Runtime = Effect.gen(function* () { const connectBackgroundService = (binaryPath: string | undefined) => Effect.gen(function* () { - const discovered = yield* OpenCodeLocalService.discover().pipe( + // A registered v1 service is skipped here so `ensure` below replaces it + // with a v2 one instead of this adapter adopting it. + const discovered = yield* OpenCodeLocalService.discover({ + version: isOpenCode2Version, + }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.mapError((cause) => ensureRuntimeError("connect", "Failed to read the local service registration.", cause), @@ -265,7 +288,7 @@ const makeOpenCode2Runtime = Effect.gen(function* () { } const command = openCode2ServiceCommand(binaryPath); const endpoint = yield* OpenCodeLocalService.ensure({ - version: (version) => version.startsWith("2."), + version: isOpenCode2Version, ...(command ? { command } : {}), }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -288,7 +311,7 @@ const makeOpenCode2Runtime = Effect.gen(function* () { } satisfies OpenCode2Connection; }); - const connect: OpenCode2RuntimeShape["connect"] = (input) => + const connect: OpenCode2Runtime["Service"]["connect"] = (input) => Effect.gen(function* () { const serverUrl = input.serverUrl?.trim(); if (serverUrl && serverUrl.length > 0) { @@ -302,7 +325,7 @@ const makeOpenCode2Runtime = Effect.gen(function* () { return yield* connectBackgroundService(input.binaryPath); }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); - const loadInventory: OpenCode2RuntimeShape["loadInventory"] = (input) => + const loadInventory: OpenCode2Runtime["Service"]["loadInventory"] = (input) => Effect.gen(function* () { const { client, directory } = input; const location = { directory }; diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 90b0b13dc258..0537e6852564 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { OpenCode2Settings, ProviderInstanceId } from "@t3tools/contracts"; -import { OpenCode2Runtime, type OpenCode2RuntimeShape } from "../provider/opencode2Runtime.ts"; +import { OpenCode2Runtime } from "../provider/opencode2Runtime.ts"; import { makeOpenCode2TextGeneration } from "./OpenCode2TextGeneration.ts"; const decodeSettings = Schema.decodeSync(OpenCode2Settings); @@ -23,7 +23,7 @@ const INSTANCE = ProviderInstanceId.make("opencode2"); const runtimeReturning = ( text: () => string, requestedModels?: Array>, -): OpenCode2RuntimeShape => ({ +): OpenCode2Runtime["Service"] => ({ connect: () => Effect.succeed({ client: { From 56bff28ee1e58d883db9376a6e30dbfe1907fe82 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 16:04:05 +0300 Subject: [PATCH 03/17] fix(server): tighten OpenCode 2 recovery, lifecycle, and probe details - scope execution lifecycle events to the root session so a finished subagent cannot complete the parent turn - reconcile permissions and forms independently on recovery instead of resolving every pending form with empty answers first - keep the server version and installed state when only inventory fails - forward previous title, linked context, and attachments to title generation and pass the refinement flag through - use the SDK brand constructors, share the form field key derivation, and drop inventory and ask fields nothing reads - follow the Effect service conventions for catchTags and namespace imports Co-Authored-By: Claude Fable 5.1 --- .../src/provider/Layers/OpenCode2Adapter.ts | 106 ++++++++++-------- .../src/provider/Layers/OpenCode2Provider.ts | 29 +++-- apps/server/src/provider/opencode2Runtime.ts | 8 -- .../OpenCode2TextGeneration.test.ts | 47 +++++++- .../textGeneration/OpenCode2TextGeneration.ts | 4 + 5 files changed, 120 insertions(+), 74 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index e3514ecf463f..1beaca5acc77 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -34,8 +34,14 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import type { Brand } from "effect/Brand"; -import { Form, Permission, Session, SessionMessage } from "@opencode/client/effect"; +import { + AbsolutePath, + Agent, + Form, + Permission, + Session, + SessionMessage, +} from "@opencode/client/effect"; import { Mcp } from "@opencode/schema/mcp"; import type { OpenCodeEvent } from "@opencode/client/effect"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -97,10 +103,8 @@ const decodeOpenCode2ResumeCursor = Schema.decodeUnknownOption(OpenCode2ResumeCu /** v2 brands its protocol ids; lift persisted plain strings back into them. */ const toSessionId = (value: string): Session.ID => Session.ID.descending(value); -const toDirectory = (value: string): string & Brand<"AbsolutePath"> => - value as string & Brand<"AbsolutePath">; -const toAgentId = (value: string): string & Brand<"Agent.ID"> => - value as string & Brand<"Agent.ID">; +const toDirectory = (value: string): AbsolutePath => AbsolutePath.make(value); +const toAgentId = (value: string): Agent.ID => Agent.ID.make(value); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -204,9 +208,6 @@ function toOpenCode2ToolItemType(toolName: string | undefined): ToolLifecycleIte } if ( normalized === "read" || - normalized === "edit" || - normalized === "write" || - normalized === "patch" || normalized === "glob" || normalized === "grep" || normalized.includes("edit") || @@ -254,7 +255,6 @@ interface OpenCode2PermissionAsk { readonly action: string; readonly resources: ReadonlyArray; readonly metadata: Record | undefined; - readonly message: string | undefined; } /** One field of a pending v2 form, with the answer key we advertise to T3. */ @@ -267,7 +267,6 @@ interface OpenCode2FormAskField { interface OpenCode2FormAsk { readonly id: string; readonly sessionID: string; - readonly title: string; readonly fields: ReadonlyArray; } @@ -525,18 +524,16 @@ export function makeOpenCode2Adapter( }, ): Effect.Effect { return Effect.map(nowIso, (updatedAt) => { - const nextSession = { - ...context.session, - ...patch, + // The `clear` flags remove the key entirely rather than setting it + // undefined. Object rest expresses that without erasing the session to + // a string map to reach `delete`. + const { activeTurnId, lastError, ...rest } = { ...context.session, ...patch }; + const nextSession: ProviderSession = { + ...rest, + ...(clear?.clearActiveTurnId === true ? {} : { activeTurnId }), + ...(clear?.clearLastError === true ? {} : { lastError }), updatedAt, - } as ProviderSession & Record; - const mutableSession = nextSession as Record; - if (clear?.clearActiveTurnId) { - delete mutableSession.activeTurnId; - } - if (clear?.clearLastError) { - delete mutableSession.lastError; - } + }; context.session = nextSession; return nextSession; }); @@ -1089,8 +1086,16 @@ export function makeOpenCode2Adapter( }); }); + /** + * The key/header pair for one form field, derived in exactly one place. + * `respondToUserInput` matches answers against `key` then `header`, so both + * the stored ask and the emitted question must agree on this derivation. + */ + const formFieldKey = (field: OpenCode2FormField, index: number): string => + field.key.trim().length > 0 ? field.key : `field-${index}`; + function questionFromFormField(field: OpenCode2FormField, index: number): UserInputQuestion { - const key = field.key.trim().length > 0 ? field.key : `field-${index}`; + const key = formFieldKey(field, index); const header = trimText(field.title) ?? key; const question = trimText(field.description) ?? header; const optionFrom = (option: { label: string; description?: string | undefined }) => ({ @@ -1161,7 +1166,6 @@ export function makeOpenCode2Adapter( form: { readonly id: string; readonly sessionID: string; - readonly title: string; readonly fields: ReadonlyArray; }, raw: unknown, @@ -1170,16 +1174,18 @@ export function makeOpenCode2Adapter( if (stopped || context.pendingForms.has(form.id) || context.resolvedRequestIds.has(form.id)) { return; } - const fields = form.fields.map((field, index) => ({ - key: field.key.trim().length > 0 ? field.key : `field-${index}`, - header: trimText(field.title) ?? `field-${index}`, - field, - })); + // The key/header derivation is shared with the emitted questions (via + // `questionFromFormField`), since `respondToUserInput` matches answers + // against `key` then `header`. + const questions = form.fields.map((field, index) => questionFromFormField(field, index)); const ask: OpenCode2FormAsk = { id: form.id, sessionID: form.sessionID, - title: form.title, - fields, + fields: form.fields.map((field, index) => ({ + key: questions[index]?.id ?? formFieldKey(field, index), + header: questions[index]?.header ?? formFieldKey(field, index), + field, + })), }; context.pendingForms.set(form.id, ask); emitUnsafe({ @@ -1190,9 +1196,7 @@ export function makeOpenCode2Adapter( raw, })), type: "user-input.requested", - payload: { - questions: form.fields.map((field, index) => questionFromFormField(field, index)), - }, + payload: { questions }, }); }); @@ -1264,11 +1268,14 @@ export function makeOpenCode2Adapter( .pipe(Effect.timeout("10 seconds"), Effect.option); if (Option.isSome(permissions)) { const presentPermissionIds = new Set(permissions.value.map((ask) => ask.id)); + // Each pass reconciles one request kind; the other kind is untouched + // until its own list arrives. yield* closePendingRequests( context, { type: "pending-requests.recovered" }, { skipPermissionIds: presentPermissionIds, + skipFormIds: new Set(context.pendingForms.keys()), }, ); for (const ask of permissions.value) { @@ -1280,7 +1287,6 @@ export function makeOpenCode2Adapter( action: ask.action, resources: ask.resources, metadata: ask.metadata, - message: ask.message, }, { type: "permission.asked", recovered: true, request: ask }, ); @@ -1295,6 +1301,7 @@ export function makeOpenCode2Adapter( context, { type: "pending-requests.recovered" }, { + skipPermissionIds: new Set(context.pendingPermissions.keys()), skipFormIds: presentFormIds, }, ); @@ -1304,7 +1311,6 @@ export function makeOpenCode2Adapter( { id: form.id, sessionID: form.sessionID, - title: form.title, fields: form.fields, }, { type: "form.created", recovered: true, form }, @@ -1473,19 +1479,18 @@ export function makeOpenCode2Adapter( } seen.add(sessionId); const currentSessionId: string = sessionId; + // A missing ancestor ends the walk; any other failure (timeout, auth, + // transport) also ends it, since ancestry is best-effort. const info = yield* context.client.session .get({ sessionID: toSessionId(currentSessionId) }) .pipe( Effect.timeout("5 seconds"), - Effect.catchTag("SessionNotFoundError", () => - Effect.succeed(undefined as Session.Info | undefined), - ), - Effect.option, + Effect.orElseSucceed((): Session.Info | undefined => undefined), ); - if (Option.isNone(info) || info.value === undefined) { + if (info === undefined) { return false; } - sessionId = info.value.parentID; + sessionId = info.parentID; } return false; }); @@ -1528,6 +1533,13 @@ export function makeOpenCode2Adapter( } const turnId = context.activeTurnId; + // Execution lifecycle is per session, and subagent child sessions run + // their own; only the root session's run maps onto the T3 turn. + const isRootExecutionEvent = + !event.type.startsWith("session.execution.") || sessionId === context.openCodeSessionId; + if (!isRootExecutionEvent) { + return; + } switch (event.type) { case "session.created": { if (event.data.parentID && context.relatedSessionIds.has(event.data.parentID)) { @@ -1951,7 +1963,6 @@ export function makeOpenCode2Adapter( action: ask.action, resources: ask.resources, metadata: ask.metadata, - message: ask.message, }, event, ); @@ -1968,7 +1979,6 @@ export function makeOpenCode2Adapter( { id: event.data.form.id, sessionID: event.data.form.sessionID, - title: event.data.form.title, fields: event.data.form.fields, }, event, @@ -2225,7 +2235,7 @@ export function makeOpenCode2Adapter( const result = yield* context.client.message.list(request).pipe( Effect.mapError(listRequestError), Effect.map((response) => ({ - data: response.data as ReadonlyArray, + data: response.data, next: response.cursor.next, })), ); @@ -2322,9 +2332,9 @@ export function makeOpenCode2Adapter( ? yield* client.session.get({ sessionID: toSessionId(resumeSessionId) }).pipe( // Typed on the decoded error union, before the boundary map // collapses the rest into a request error. - Effect.catchTag("SessionNotFoundError", () => - Effect.succeed(undefined as Session.Info | undefined), - ), + Effect.catchTags({ + SessionNotFoundError: () => Effect.succeed(undefined), + }), Effect.mapError( toRequestError("session.get", "Failed to read the OpenCode 2 session."), ), diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts index 71546d12daa4..24a99225bf81 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -16,11 +16,7 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - OpenCode2Runtime, - OpenCode2RuntimeError, - type OpenCode2Inventory, -} from "../opencode2Runtime.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; const OPENCODE2_PRESENTATION = { displayName: "OpenCode 2", @@ -55,7 +51,9 @@ const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapab ], }); -function flattenOpenCode2Models(inventory: OpenCode2Inventory): ReadonlyArray { +function flattenOpenCode2Models( + inventory: OpenCode2Runtime.OpenCode2Inventory, +): ReadonlyArray { return inventory.models.flatMap((model) => { const name = nonEmptyTrimmed(model.name); if (!name) return []; @@ -73,7 +71,7 @@ function flattenOpenCode2Models(inventory: OpenCode2Inventory): ReadonlyArray { const skills: Array = []; for (const skill of input ?? []) { @@ -125,8 +123,8 @@ export const makePendingOpenCode2Provider = ( export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderStatus")(function* ( openCode2Settings: OpenCode2Settings, cwd: string, -): Effect.fn.Return { - const openCode2Runtime = yield* OpenCode2Runtime; +): Effect.fn.Return { + const openCode2Runtime = yield* OpenCode2Runtime.OpenCode2Runtime; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCode2Settings.customModels; const isExternalServer = openCode2Settings.serverUrl.trim().length > 0; @@ -134,15 +132,16 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta // `connect` and `loadInventory` already fail with an `OpenCode2RuntimeError` // carrying a human-readable `detail`, so the probe surfaces that directly // instead of re-wrapping the failure to recover the message. - const fallback = (detail: string) => + const fallback = (detail: string, connection?: OpenCode2Runtime.OpenCode2Connection) => buildServerProvider({ presentation: OPENCODE2_PRESENTATION, enabled: openCode2Settings.enabled, checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE2_MODEL_CAPABILITIES), probe: { - installed: false, - version: null, + // A reachable server that fails inventory is still installed. + installed: connection !== undefined, + version: connection?.version ?? null, status: "error", auth: { status: "unknown" }, message: detail, @@ -181,7 +180,7 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta duration: OPENCODE2_PROBE_TIMEOUT, orElse: () => Effect.fail( - new OpenCode2RuntimeError({ + new OpenCode2Runtime.OpenCode2RuntimeError({ operation: "connect", detail: "OpenCode 2 connection probe timed out.", }), @@ -201,7 +200,7 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta duration: OPENCODE2_PROBE_TIMEOUT, orElse: () => Effect.fail( - new OpenCode2RuntimeError({ + new OpenCode2Runtime.OpenCode2RuntimeError({ operation: "inventory", detail: "OpenCode 2 inventory loading timed out.", }), @@ -210,7 +209,7 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta Effect.result, ); if (Result.isFailure(inventoryResult)) { - return fallback(inventoryResult.failure.detail); + return fallback(inventoryResult.failure.detail, connection); } const inventory = inventoryResult.success; diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index bc360e3f79f3..5edfd7b0e851 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -157,12 +157,8 @@ export interface OpenCode2Inventory { readonly id: string; readonly providerId: string; readonly name: string; - readonly contextWindow: number; - readonly maxOutputTokens: number; - readonly status?: string; }>; readonly skills: ReadonlyArray<{ - readonly id: string; readonly name: string; readonly description?: string | null; readonly location: string; @@ -351,12 +347,8 @@ const makeOpenCode2Runtime = Effect.gen(function* () { id: model.id, providerId: model.providerID, name: model.name, - contextWindow: model.limit.context, - maxOutputTokens: model.limit.output, - status: model.status, })), skills: skillPage.data.map((skill) => ({ - id: skill.id, name: skill.name, description: skill.description ?? null, location: skill.location, diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 0537e6852564..0be80581daaa 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -23,14 +23,16 @@ const INSTANCE = ProviderInstanceId.make("opencode2"); const runtimeReturning = ( text: () => string, requestedModels?: Array>, + requestedPrompts?: Array, ): OpenCode2Runtime["Service"] => ({ connect: () => Effect.succeed({ client: { generate: { - text: (input: { readonly model?: Record }) => + text: (input: { readonly model?: Record; readonly prompt?: string }) => Effect.sync(() => { requestedModels?.push(input.model ?? {}); + requestedPrompts?.push(input.prompt ?? ""); return { text: text() }; }), }, @@ -42,11 +44,15 @@ const runtimeReturning = ( loadInventory: () => Effect.die("not used"), }); -const makeTextGeneration = (text: () => string, requestedModels?: Array>) => +const makeTextGeneration = ( + text: () => string, + requestedModels?: Array>, + requestedPrompts?: Array, +) => makeOpenCode2TextGeneration(decodeSettings({ enabled: true })).pipe( Effect.provideService( OpenCode2Runtime, - OpenCode2Runtime.of(runtimeReturning(text, requestedModels)), + OpenCode2Runtime.of(runtimeReturning(text, requestedModels, requestedPrompts)), ), ); @@ -127,3 +133,38 @@ it.effect("fails with a TextGenerationError when the output is not JSON", () => NodeAssert.equal(error?._tag, "TextGenerationError"); }), ); + +it.effect("uses the regeneration prompt when a previous title is supplied", () => + Effect.gen(function* () { + // Regression: only `message` was forwarded, so regenerating a title always + // built the *initial* prompt and the model never saw the previous title. + const requestedPrompts: Array = []; + const textGeneration = yield* makeTextGeneration( + () => JSON.stringify({ title: "Fix flaky CI job" }), + undefined, + requestedPrompts, + ); + yield* textGeneration.generateThreadTitle({ + message: "investigate the flaky CI job", + previousTitle: "Investigate CI", + cwd: process.cwd(), + modelSelection: createModelSelection(INSTANCE, "openai/gpt-6-astra"), + }); + + NodeAssert.equal(requestedPrompts.length, 1); + NodeAssert.ok( + requestedPrompts[0]?.includes("Investigate CI"), + "regeneration prompt should carry the previous title", + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("surfaces needsRefinement from the model", () => + Effect.gen(function* () { + // Regression: the field was dropped, so the refine-title flow never saw it. + const result = yield* generateTitle(() => + JSON.stringify({ title: "Untitled", needsRefinement: true }), + ); + NodeAssert.equal(result.needsRefinement, true); + }), +); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts index 39be57c05b76..036a9ccf70f9 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -197,6 +197,9 @@ export const makeOpenCode2TextGeneration = (openCode2Settings: OpenCode2Settings Effect.gen(function* () { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, + previousTitle: input.previousTitle, + linkedContext: input.linkedContext, + attachments: input.attachments, }); const generated = yield* generate({ operation: "generateThreadTitle", @@ -207,6 +210,7 @@ export const makeOpenCode2TextGeneration = (openCode2Settings: OpenCode2Settings return { title: sanitizeThreadTitle(generated.title), + ...(generated.needsRefinement ? { needsRefinement: true } : {}), }; }); From 4f7e4d49ddd88d4625b968fa6ec9d16f6cad685b Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 16:07:45 +0300 Subject: [PATCH 04/17] fix(server): catch the OpenCode 2 schema failure with catchTags Co-Authored-By: Claude Fable 5.1 --- apps/server/src/textGeneration/OpenCode2TextGeneration.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts index 036a9ccf70f9..237522517c04 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -98,15 +98,14 @@ export const makeOpenCode2TextGeneration = (openCode2Settings: OpenCode2Settings // object rather than to a string (mirrors the other providers). const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); return yield* decodeOutput(extractJsonObject(rawText)).pipe( - Effect.catchTag( - "SchemaError", - (cause) => + Effect.catchTags({ + SchemaError: (cause) => new TextGenerationError({ operation: input.operation, detail: "OpenCode 2 returned invalid structured output.", cause, }), - ), + }), ); }).pipe( Effect.catchTags({ From 62ae128e773d604d5135a177d1fd1ddea5fe4dfd Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 16:15:18 +0300 Subject: [PATCH 05/17] fix(server): settle OpenCode 2 turns and requests after interruptions - let an idle status complete an admitted turn whose run ended without a terminal event, such as a shutdown or inactivity interruption - re-register the thread MCP server when reconciling after a reconnect - clear the auto-reply marker before falling back to a permission dialog so the user's answer is not swallowed Co-Authored-By: Claude Fable 5.1 --- .../src/provider/Layers/OpenCode2Adapter.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 1beaca5acc77..583a340a6c08 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -1026,7 +1026,10 @@ export function makeOpenCode2Adapter( if (!replied) { // Fall back to the dialog. The id stays resolved so a recovered copy // of this ask cannot reopen after the user answers; - // `pendingPermissions` gates re-asks while the dialog is open. + // `pendingPermissions` gates re-asks while the dialog is open. The + // auto-reply marker must go, or the user's answer would be swallowed + // as the terminal event of a reply that never landed. + context.autoRepliedRequestIds.delete(ask.id); yield* openPermissionRequest(context, ask, raw); } }); @@ -1697,10 +1700,12 @@ export function makeOpenCode2Adapter( break; } // idle: reconciliation only — the explicit execution events own - // turn completion, this covers a missed terminal event. + // turn completion, this covers a missed terminal event. An + // admitted prompt falls through: its run may have ended without + // a terminal event (e.g. a non-user interruption). if (turnId !== undefined) { const admission = context.promptAdmission; - if (admission?.turnId === turnId) { + if (admission?.turnId === turnId && !admission.accepted) { yield* schedulePromptAdmissionRecovery(context, admission); break; } @@ -1720,7 +1725,7 @@ export function makeOpenCode2Adapter( case "session.idle": { if (turnId !== undefined) { const admission = context.promptAdmission; - if (admission?.turnId === turnId) { + if (admission?.turnId === turnId && !admission.accepted) { yield* schedulePromptAdmissionRecovery(context, admission); break; } @@ -2038,6 +2043,9 @@ export function makeOpenCode2Adapter( }) .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); } + // A restarted server has forgotten the thread's MCP registration too; + // `mcp.add` is a plain upsert, so re-sending it is safe when it has not. + yield* registerThreadMcp(context).pipe(Effect.ignoreCause); yield* recoverPendingRequests(context); if (context.activeTurnId !== undefined) { const turnId = context.activeTurnId; From 42fd3eb34db4b56173a27afe5ce2199683794b05 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 16 Sep 2026 19:17:55 +0300 Subject: [PATCH 06/17] fix(server): offer only the variants an OpenCode 2 model declares OpenCode 2 rejects a model ref whose variant the model does not declare, so the fixed low/medium/high/xhigh selector failed every turn on models without variants (e.g. opencode/union-alpha). Build the Reasoning options from the model's own variant list and omit the selector when it is empty. --- .../src/provider/Layers/OpenCode2Provider.ts | 81 +++++++++++++------ apps/server/src/provider/opencode2Runtime.ts | 3 + 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts index 24a99225bf81..69e491d4787b 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -24,33 +24,64 @@ const OPENCODE2_PRESENTATION = { } as const; const OPENCODE2_PROBE_TIMEOUT = "8 seconds"; -const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra High" }, - ], - currentValue: "medium", - }, - { - id: "agent", - label: "Agent", - type: "select", - options: [ - { id: "build", label: "Build", isDefault: true }, - { id: "plan", label: "Plan" }, - ], - currentValue: "build", - }, +const OPENCODE2_AGENT_DESCRIPTOR = { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, ], + currentValue: "build", +} as const; + +/** + * Capabilities for models whose variants are unknown (custom models, the + * fallback catalog). No Reasoning selector: OpenCode 2 rejects a variant the + * model does not declare, so guessing one breaks every turn. + */ +const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [OPENCODE2_AGENT_DESCRIPTOR], }); +function titleCaseSlug(value: string): string { + return value + .split(/[-_\s]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +/** Reasoning options come from the model's own declared variants. */ +function openCode2CapabilitiesForModel( + model: OpenCode2Runtime.OpenCode2Inventory["models"][number], +): ModelCapabilities { + if (model.variants.length === 0) { + return DEFAULT_OPENCODE2_MODEL_CAPABILITIES; + } + const defaultVariant = model.variants.includes("medium") + ? "medium" + : model.variants.includes("high") + ? "high" + : undefined; + return createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: model.variants.map((variant) => + variant === defaultVariant + ? { id: variant, label: titleCaseSlug(variant), isDefault: true as const } + : { id: variant, label: titleCaseSlug(variant) }, + ), + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + OPENCODE2_AGENT_DESCRIPTOR, + ], + }); +} + function flattenOpenCode2Models( inventory: OpenCode2Runtime.OpenCode2Inventory, ): ReadonlyArray { @@ -64,7 +95,7 @@ function flattenOpenCode2Models( name, ...(subProvider ? { subProvider } : {}), isCustom: false, - capabilities: DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + capabilities: openCode2CapabilitiesForModel(model), } satisfies ServerProviderModel, ]; }); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 5edfd7b0e851..6638d38f58b4 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -157,6 +157,8 @@ export interface OpenCode2Inventory { readonly id: string; readonly providerId: string; readonly name: string; + /** Variant ids the model accepts; v2 rejects any other in the model ref. */ + readonly variants: ReadonlyArray; }>; readonly skills: ReadonlyArray<{ readonly name: string; @@ -347,6 +349,7 @@ const makeOpenCode2Runtime = Effect.gen(function* () { id: model.id, providerId: model.providerID, name: model.name, + variants: model.variants.map((variant) => variant.id), })), skills: skillPage.data.map((skill) => ({ name: skill.name, From fcb1d3c8374cd8483d8fca8636b442c72334cdc1 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 17 Sep 2026 00:35:53 +0300 Subject: [PATCH 07/17] chore(server): bump @opencode/client and @opencode/schema to 2.0.5 --- apps/server/package.json | 4 +-- ....3.patch => @opencode__client@2.0.5.patch} | 0 pnpm-lock.yaml | 34 +++++++++---------- pnpm-workspace.yaml | 5 ++- 4 files changed, 23 insertions(+), 20 deletions(-) rename patches/{@opencode__client@2.0.3.patch => @opencode__client@2.0.5.patch} (100%) diff --git a/apps/server/package.json b/apps/server/package.json index 5b718eacd545..5bc8944d4b84 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,8 +28,8 @@ "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", - "@opencode/client": "2.0.3", - "@opencode/schema": "2.0.3", + "@opencode/client": "2.0.5", + "@opencode/schema": "2.0.5", "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", diff --git a/patches/@opencode__client@2.0.3.patch b/patches/@opencode__client@2.0.5.patch similarity index 100% rename from patches/@opencode__client@2.0.3.patch rename to patches/@opencode__client@2.0.5.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6738356152ea..bc4c88fc282c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,7 +90,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 - '@opencode/client@2.0.3': ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2 + '@opencode/client@2.0.5': ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -511,11 +511,11 @@ importers: specifier: ^1.3.15 version: 1.15.13 '@opencode/client': - specifier: 2.0.3 - version: 2.0.3(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + specifier: 2.0.5 + version: 2.0.5(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@opencode/schema': - specifier: 2.0.3 - version: 2.0.3 + specifier: 2.0.5 + version: 2.0.5 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) @@ -3704,8 +3704,8 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} - '@opencode/client@2.0.3': - resolution: {integrity: sha512-b+LcpMI131fXnGqf+O+9Y33H4XmwAkQibfYvtpEOROftkTpkcMj3R7WW/wGl2VTBNTZECAk2Yd+TACTzzAwk0A==} + '@opencode/client@2.0.5': + resolution: {integrity: sha512-+eCp8XOzONSLDdCRCKJfD7JO8ofTxMyBhuuskgAETFDw0HSgHY40TDmcAZqWh6W9pMDElGJv9L1mDaufPPp6AQ==} peerDependencies: effect: 4.0.0-rc.112 solid-js: '>=1.9.0' @@ -3715,11 +3715,11 @@ packages: solid-js: optional: true - '@opencode/protocol@2.0.3': - resolution: {integrity: sha512-DNHbkLDTuAyMsmVdnjuKulcfoDbllW2dxt4W19r2aJEYvswJpEO9168CSK6Sjbx1FOh/B9yL7SQgoDRMd4K5Vw==} + '@opencode/protocol@2.0.5': + resolution: {integrity: sha512-RUuDNN87/ZK8nuPQnAhlyQ6szt+xllgQFk4MIhsU0W1Oez1C7b7w8Sv+p7xnylLfnEHVlz108MTeJTsmbNQKLw==} - '@opencode/schema@2.0.3': - resolution: {integrity: sha512-thPeBbqw4+SkZ/0LOuqfahSRGuWogRa/wwfIVyiQYsoE0vzLx8XUpHQ+FSUWkdz6llDtP0+ZsyBqXUMpXmPgbg==} + '@opencode/schema@2.0.5': + resolution: {integrity: sha512-Cf6HyFDROTjipoCYFkv+6RoKGMSXZEgbjAvMBy2teUtAluP1APcCIvqDGutueUdqAlzqIeaWHwJTxLXbyKAq5w==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -14552,19 +14552,19 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@opencode/client@2.0.3(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': + '@opencode/client@2.0.5(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@opencode/protocol': 2.0.3 - '@opencode/schema': 2.0.3 + '@opencode/protocol': 2.0.5 + '@opencode/schema': 2.0.5 optionalDependencies: effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@opencode/protocol@2.0.3': + '@opencode/protocol@2.0.5': dependencies: - '@opencode/schema': 2.0.3 + '@opencode/schema': 2.0.5 effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@opencode/schema@2.0.3': + '@opencode/schema@2.0.5': dependencies: '@standard-schema/spec': 1.1.0 effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0461d6c30563..d62ecd379858 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -87,6 +87,9 @@ minimumReleaseAgeExclude: - expo-widgets@57.0.15 - electron@44.1.0 - "@anthropic-ai/claude-agent-sdk@0.3.260" + - "@opencode/client@2.0.5" + - "@opencode/protocol@2.0.5" + - "@opencode/schema@2.0.5" overrides: # Earlier React Navigation declarations create a circular type alias in TypeScript 7. @@ -162,7 +165,7 @@ patchedDependencies: "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch - "@opencode/client@2.0.3": patches/@opencode__client@2.0.3.patch + "@opencode/client@2.0.5": patches/@opencode__client@2.0.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch From 8e67120ca01306efb0b3b0ddfdd2a4a4f71a79a9 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 17 Sep 2026 01:23:53 +0300 Subject: [PATCH 08/17] fix(server): port OpenCode 2 adapter to @opencode/client 2.0.5 API The 2.0.4 SDK renamed several fields and regrouped endpoints: session.interrupt continue -> resume, permission.reply reply -> decision, form.list/reply moved under session.form, permission.rules removed in favor of session.update, session.fork boundary -> before, health probe moved to server.status (/api/status), and Skill.Info location -> path. Includes the regenerated Nix pnpm dependency hash. --- .../src/provider/Layers/OpenCode2Adapter.ts | 46 ++++++++----------- .../src/provider/opencode2Runtime.test.ts | 15 +++--- apps/server/src/provider/opencode2Runtime.ts | 12 ++--- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 583a340a6c08..39bc0573e664 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -843,7 +843,7 @@ export function makeOpenCode2Adapter( const detail = "OpenCode 2 accepted the prompt, but T3 Code could not confirm the session started executing it."; yield* context.client.session - .interrupt({ sessionID: toSessionId(context.openCodeSessionId), continue: false }) + .interrupt({ sessionID: toSessionId(context.openCodeSessionId), resume: false }) .pipe(Effect.timeout("1 second"), Effect.ignore); const tokenUsage = takeTurnUsage(context, false); context.promptAdmission = undefined; @@ -1016,7 +1016,7 @@ export function makeOpenCode2Adapter( .reply({ sessionID: toSessionId(ask.sessionID), requestID: Permission.ID.create(ask.id), - reply: "once", + decision: "once", }) .pipe( Effect.timeout("10 seconds"), @@ -1295,7 +1295,7 @@ export function makeOpenCode2Adapter( ); } } - const forms = yield* context.client.form + const forms = yield* context.client.session.form .list({ sessionID: context.openCodeSessionId }) .pipe(Effect.timeout("10 seconds"), Effect.option); if (Option.isSome(forms)) { @@ -1332,7 +1332,7 @@ export function makeOpenCode2Adapter( // session, so tell it to stop any run it still owns. if (options.interruptRemote) { yield* context.client.session - .interrupt({ sessionID: toSessionId(context.openCodeSessionId), continue: false }) + .interrupt({ sessionID: toSessionId(context.openCodeSessionId), resume: false }) .pipe(Effect.timeout("1 second"), Effect.ignore); } // Best-effort deregistration of this thread's MCP server; concurrent @@ -2036,8 +2036,8 @@ export function makeOpenCode2Adapter( runtimeMode: context.appliedRulesMode, ownMcpServerName: context.mcpServerName, }); - yield* context.client.permission - .rules({ + yield* context.client.session + .update({ sessionID: toSessionId(context.openCodeSessionId), permissions: ruleset, }) @@ -2153,14 +2153,14 @@ export function makeOpenCode2Adapter( runtimeMode, ownMcpServerName: context.mcpServerName, }); - yield* context.client.permission - .rules({ + yield* context.client.session + .update({ sessionID: toSessionId(context.openCodeSessionId), permissions: ruleset, }) .pipe( Effect.timeout("10 seconds"), - Effect.mapError(toRequestError("permission.rules", "Failed to set session rules.")), + Effect.mapError(toRequestError("session.update", "Failed to set session rules.")), ); context.appliedRulesMode = runtimeMode; }); @@ -2377,14 +2377,11 @@ export function makeOpenCode2Adapter( // original permissions. An empty ruleset is sent deliberately: // it is what clears a supervised session's rules when the thread // has since moved to full-access. - yield* client.permission - .rules({ sessionID: adopted.id, permissions: ruleset }) + yield* client.session + .update({ sessionID: adopted.id, permissions: ruleset }) .pipe( Effect.mapError( - toRequestError( - "permission.rules", - "Failed to re-apply session rules on resume.", - ), + toRequestError("session.update", "Failed to re-apply session rules on resume."), ), ); if (agent) { @@ -2896,7 +2893,7 @@ export function makeOpenCode2Adapter( yield* context.client.session .interrupt({ sessionID: toSessionId(context.openCodeSessionId), - continue: false, + resume: false, }) .pipe( Effect.timeout("10 seconds"), @@ -2936,7 +2933,7 @@ export function makeOpenCode2Adapter( .reply({ sessionID: toSessionId(ask.sessionID), requestID: Permission.ID.create(requestId), - reply, + decision: reply, }) .pipe( Effect.timeout("10 seconds"), @@ -3015,7 +3012,7 @@ export function makeOpenCode2Adapter( } } - yield* context.client.form + yield* context.client.session.form .reply({ sessionID: form.sessionID, formID: Form.ID.create(form.id), @@ -3025,7 +3022,7 @@ export function makeOpenCode2Adapter( Effect.timeout("10 seconds"), Effect.mapError( toRequestError( - "form.reply", + "session.form.reply", "OpenCode 2 form reply did not complete within 10 seconds.", ), ), @@ -3110,10 +3107,7 @@ export function makeOpenCode2Adapter( const fork = yield* context.client.session .fork({ sessionID: toSessionId(context.openCodeSessionId), - boundary: { - type: "before", - messageID: firstRemovedMessageId, - }, + before: firstRemovedMessageId, }) .pipe(Effect.mapError(toRequestError("session.fork", "OpenCode 2 session fork failed."))); const forkedSessionId = fork.id; @@ -3135,11 +3129,11 @@ export function makeOpenCode2Adapter( runtimeMode: context.appliedRulesMode, ownMcpServerName: context.mcpServerName, }); - yield* context.client.permission - .rules({ sessionID: forkedSessionId, permissions: ruleset }) + yield* context.client.session + .update({ sessionID: forkedSessionId, permissions: ruleset }) .pipe( Effect.mapError( - toRequestError("permission.rules", "Failed to set rules on the forked session."), + toRequestError("session.update", "Failed to set rules on the forked session."), ), ); } diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts index 7cbba91c2baf..6ce78d1ec2ed 100644 --- a/apps/server/src/provider/opencode2Runtime.test.ts +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -20,13 +20,13 @@ import { withOpenCode2Variant, } from "./opencode2Runtime.ts"; -const HEALTH_BODY = { healthy: true, version: "2.0.3", pid: 1234 }; +const HEALTH_BODY = { version: "2.0.5", pid: 1234, urls: [] as Array }; /** * Records every request URL while answering the health probe. Recording the * request is the point: the external-server path used to normalize the * configured URL with `url.parse(...).toString()`, which evaluates to the - * string "[object Object]" — the probe then requested "[object Object]/api/health" + * string "[object Object]" — the probe then requested "[object Object]/api/status" * and every external server looked unreachable. Asserting on the runtime's own * arguments would not have caught that. */ @@ -65,8 +65,8 @@ it.effect("probes an external server at its configured URL", () => NodeAssert.equal(exit._tag, "Success"); if (exit._tag !== "Success") return; NodeAssert.equal(exit.value.url, "http://127.0.0.1:49374"); - NodeAssert.equal(exit.value.version, "2.0.3"); - NodeAssert.deepEqual(requestedUrls, ["http://127.0.0.1:49374/api/health"]); + NodeAssert.equal(exit.value.version, "2.0.5"); + NodeAssert.deepEqual(requestedUrls, ["http://127.0.0.1:49374/api/status"]); }), ); @@ -75,15 +75,16 @@ it.effect("keeps a trailing slash from corrupting the probe URL", () => const { requestedUrls } = yield* connectWithRequestLog("http://127.0.0.1:49374/"); NodeAssert.equal(requestedUrls.length, 1); - NodeAssert.ok(requestedUrls[0]?.includes("127.0.0.1:49374/api/health")); + NodeAssert.ok(requestedUrls[0]?.includes("127.0.0.1:49374/api/status")); NodeAssert.ok(!requestedUrls[0]?.includes("[object Object]")); }), ); it.effect("rejects a healthy external server that is not a 2.x release", () => Effect.gen(function* () { - // A v1 server answers the health probe too; adopting it would only fail - // later on the first v2-only call, with no hint about the version. + // A server answering the status probe with a non-2.x version is rejected + // here; adopting it would only fail later on the first v2-only call, + // with no hint about the version. const { exit } = yield* connectWithRequestLog("http://127.0.0.1:49374", { ...HEALTH_BODY, version: "1.12.4", diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 6638d38f58b4..0024f24abe87 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -26,7 +26,7 @@ export interface OpenCode2Connection { readonly client: OpenCodeClient; readonly url: string; readonly external: boolean; - /** Server version from `health.get`, resolved at connect time. */ + /** Server version from `server.status`, resolved at connect time. */ readonly version: string; } @@ -225,12 +225,12 @@ const makeOpenCode2Runtime = Effect.gen(function* () { ); /** - * `health.get` is the connection's liveness + version probe in v2. A healthy - * v1 server answers it too, so the version is gated here: adopting one would - * only fail later, on the first v2-only call. + * `server.status` is the connection's liveness + version probe in v2. A v1 + * server does not serve it, so adopting one fails here with "did not + * respond"; the version gate below stays as a second line of defense. */ const probeConnection = (client: OpenCodeClient) => - client.health.get().pipe( + client.server.status().pipe( Effect.timeout(OPENCODE2_CONNECT_TIMEOUT), Effect.mapError((cause) => ensureRuntimeError( @@ -354,7 +354,7 @@ const makeOpenCode2Runtime = Effect.gen(function* () { skills: skillPage.data.map((skill) => ({ name: skill.name, description: skill.description ?? null, - location: skill.location, + location: skill.path, })), } satisfies OpenCode2Inventory; }); From 9d81032529c3f2922fac514cf659cce3621c69b2 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 17 Sep 2026 09:23:48 +0300 Subject: [PATCH 09/17] fix(server): compose the OpenCode 2 platform layer at the server and clear interrupt markers on failure --- apps/server/src/provider/Layers/OpenCode2Adapter.ts | 12 +++++++++--- apps/server/src/provider/opencode2Runtime.ts | 9 +++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 39bc0573e664..d71d7d26dd1f 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -2903,10 +2903,16 @@ export function makeOpenCode2Adapter( "OpenCode 2 session interrupt did not complete within 10 seconds.", ), ), + // The marker only suppresses completion while the interrupt is in + // flight; a failed interrupt must not strand a turn that later ends. + Effect.ensuring( + Effect.sync(() => { + if (context.cancellationTurnId === targetTurnId) { + context.cancellationTurnId = undefined; + } + }), + ), ); - if (context.cancellationTurnId === targetTurnId) { - context.cancellationTurnId = undefined; - } // The `session.execution.interrupted` event usually drives the abort; // emit locally too so a stream outage cannot strand a running turn. if (targetTurnId) { diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 0024f24abe87..2139a74953ce 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -8,7 +8,6 @@ import * as P from "effect/Predicate"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as NodeURL from "node:url"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { Model, OpenCode, Permission } from "@opencode/client/effect"; import { Service as OpenCodeLocalService } from "@opencode/client/effect/service"; import type { OpenCodeClient } from "@opencode/client/effect"; @@ -362,11 +361,9 @@ const makeOpenCode2Runtime = Effect.gen(function* () { return OpenCode2Runtime.of({ connect, loadInventory }); }); -export const OpenCode2RuntimeLive = Layer.effect(OpenCode2Runtime, makeOpenCode2Runtime).pipe( - // `HttpClient` (API calls) and `FileSystem` (local service registration file) - // both come from the Node platform layer. - Layer.provide(NodeServices.layer), -); +// Requires `HttpClient` (API calls) and `FileSystem` (local service +// registration file); the platform layer is composed in `server.ts`. +export const OpenCode2RuntimeLive = Layer.effect(OpenCode2Runtime, makeOpenCode2Runtime); export type { OpenCodeClient }; From a6267df10c02a35129350a5b4859657521b2d958 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Fri, 18 Sep 2026 13:12:56 +0000 Subject: [PATCH 10/17] fix(server): stop labeling OpenCode 2 reads as changed files (#234) --- .../OpenCode2Adapter.toolMapping.test.ts | 34 ++++++++++++++++ .../src/provider/Layers/OpenCode2Adapter.ts | 40 ++++++++++++++----- .../src/work-log/presentation.test.ts | 29 ++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/Layers/OpenCode2Adapter.toolMapping.test.ts diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.toolMapping.test.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.toolMapping.test.ts new file mode 100644 index 000000000000..24947e71ed7d --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.toolMapping.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { openCode2ToolTitle, toOpenCode2ToolItemType } from "./OpenCode2Adapter.ts"; + +/** + * Both clients derive a tool row's group from its canonical item type and, for + * reads, from the "Read file" title. `file_change` is the edit bucket, so a + * read typed as one renders "Changed N files" for calls that only read. + */ +describe("OpenCode2 tool mapping", () => { + it.each(["read", "glob", "grep", "READ"])("does not type %s as a file change", (toolName) => { + expect(toOpenCode2ToolItemType(toolName)).toBe("dynamic_tool_call"); + }); + + it.each(["read", "glob", "grep"])("titles %s as a read", (toolName) => { + expect(openCode2ToolTitle(toolName)).toEqual({ title: "Read file" }); + }); + + it.each([ + ["shell", "command_execution"], + ["edit", "file_change"], + ["write", "file_change"], + ["multiedit", "file_change"], + ["webfetch", "web_search"], + ["subagent", "collab_agent_tool_call"], + ] as const)("keeps %s as %s", (toolName, itemType) => { + expect(toOpenCode2ToolItemType(toolName)).toBe(itemType); + expect(openCode2ToolTitle(toolName)).toEqual({ title: toolName }); + }); + + it("omits the title when the tool is unknown", () => { + expect(openCode2ToolTitle(undefined)).toEqual({}); + }); +}); diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index d71d7d26dd1f..bfbdd3b64e51 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -193,23 +193,33 @@ function requestTypeForAction( } } +/** + * Read-only v2 tools. They must never map to `file_change`: clients group that + * item type — and any `path` their input contributes to `changedFiles` — as an + * edit, which rendered reads as "Changed N files". + */ +const OPENCODE2_READ_ONLY_TOOLS = new Set(["read", "glob", "grep"]); + /** * v2 tool names → canonical tool item types. Names differ from v1 (bash → - * shell); the glob/grep read-only tools group under file_change per the v2 - * docs table. + * shell). Read-only tools become `dynamic_tool_call` with the canonical + * "Read file" title, the same shape ACP adapters emit, so both clients group + * them under "Read N files" instead of the edit bucket. */ -function toOpenCode2ToolItemType(toolName: string | undefined): ToolLifecycleItemType { +export function toOpenCode2ToolItemType(toolName: string | undefined): ToolLifecycleItemType { const normalized = (toolName ?? "").toLowerCase(); - if (normalized === "todowrite" || normalized === "todoread" || normalized === "todo") { + if ( + OPENCODE2_READ_ONLY_TOOLS.has(normalized) || + normalized === "todowrite" || + normalized === "todoread" || + normalized === "todo" + ) { return "dynamic_tool_call"; } if (normalized === "shell" || normalized.includes("bash") || normalized.includes("command")) { return "command_execution"; } if ( - normalized === "read" || - normalized === "glob" || - normalized === "grep" || normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch") || @@ -236,6 +246,14 @@ function toOpenCode2ToolItemType(toolName: string | undefined): ToolLifecycleIte return "dynamic_tool_call"; } +/** Activity title for a v2 tool; see {@link toOpenCode2ToolItemType}. */ +export function openCode2ToolTitle(toolName: string | undefined): { title?: string } { + if (toolName === undefined) { + return {}; + } + return { title: OPENCODE2_READ_ONLY_TOOLS.has(toolName.toLowerCase()) ? "Read file" : toolName }; +} + const OPENCODE2_DEFAULT_TITLE_PATTERN = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -1818,7 +1836,7 @@ export function makeOpenCode2Adapter( payload: { itemType: toOpenCode2ToolItemType(toolName), status: "inProgress", - ...(toolName ? { title: toolName } : {}), + ...openCode2ToolTitle(toolName), data: { ...(toolName ? { tool: toolName } : {}), input: event.data.input, @@ -1844,7 +1862,7 @@ export function makeOpenCode2Adapter( payload: { itemType: toOpenCode2ToolItemType(toolName), status: "inProgress", - ...(toolName ? { title: toolName } : {}), + ...openCode2ToolTitle(toolName), data: event.data.metadata, }, }); @@ -1871,7 +1889,7 @@ export function makeOpenCode2Adapter( payload: { itemType, status: "completed", - ...(toolName ? { title: toolName } : {}), + ...openCode2ToolTitle(toolName), ...(output.length > 0 ? { detail: output } : {}), data: { ...(toolName ? { tool: toolName } : {}), @@ -1899,7 +1917,7 @@ export function makeOpenCode2Adapter( payload: { itemType: toOpenCode2ToolItemType(toolName), status: "failed", - ...(toolName ? { title: toolName } : {}), + ...openCode2ToolTitle(toolName), detail: event.data.error.message, }, }); diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index a0909117ad39..ab2485c7e47c 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -203,6 +203,20 @@ describe("summarizeToolGroup", () => { ]), ).toBe("Used Browser and Computer Use"); }); + + it("summarizes reads carrying input paths as reads, not edits", () => { + // OpenCode 2 reads arrive with the file path in changedFiles; before the + // adapter stopped typing them as file_change, this group said "Changed 2 + // files" for calls that never wrote. + const read = (path: string): WorkLogPresentationEntry => ({ + label: "read", + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "Read file", + changedFiles: [path], + }); + expect(summarizeToolGroup([read("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/repo/a.ts"), read("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/repo/b.ts")])).toBe("Read 2 files"); + }); }); describe("resolveWorkEntryToolPresentation", () => { @@ -552,6 +566,21 @@ describe("toolGroupAction", () => { }), ).toBe("read"); }); + + it("keeps a titled read out of the edit bucket when its input carries a path", () => { + // OpenCode 2 emits reads as dynamic_tool_call with a "Read file" title, but + // their input path also lands in changedFiles. Read must win, or the group + // renders "Changed N files" for calls that only read. + expect( + toolGroupAction({ + label: "read", + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "Read file", + changedFiles: ["/repo/src/index.ts"], + }), + ).toBe("read"); + }); }); describe("resolveViewedImageAsset", () => { From 549bd583abeb2a0b8c9020848c83162b12d627f7 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Fri, 18 Sep 2026 14:17:40 +0000 Subject: [PATCH 11/17] fix(server): route OpenCode 2 subagents to the agents surface (#235) --- .../provider/Layers/OpenCode2Adapter.test.ts | 982 ++++++++++++++++++ .../src/provider/Layers/OpenCode2Adapter.ts | 433 +++++++- 2 files changed, 1400 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/provider/Layers/OpenCode2Adapter.test.ts 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..45655af6027f --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts @@ -0,0 +1,982 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import { OpenCode2Settings, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import type { OpenCodeEvent } from "@opencode/client/effect"; +import { ServerConfig } from "../../config.ts"; +import { runtimeEventToActivities } from "../../orchestration/Layers/ProviderRuntimeIngestion.ts"; +import type { OpenCode2AdapterShape } from "../Services/OpenCode2Adapter.ts"; +import { OpenCode2Runtime } from "../opencode2Runtime.ts"; +import type { OpenCodeClient } from "../opencode2Runtime.ts"; +import { makeOpenCode2Adapter } from "./OpenCode2Adapter.ts"; + +const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("opencode2-subagent-test"); +const ROOT_SESSION_ID = "ses_root"; +const CHILD_SESSION_ID = "ses_child"; +const OPENCODE2_SETTINGS = Schema.decodeSync(OpenCode2Settings)({}); + +type ContentDeltaEvent = Extract; +type RequestOpenedEvent = Extract; +type TaskStartedEvent = Extract; +type TaskProgressEvent = Extract; +type TaskCompletedEvent = Extract; + +const isContentDeltaFor = + (itemId: string) => + (event: ProviderRuntimeEvent): event is ContentDeltaEvent => + event.type === "content.delta" && event.itemId === itemId; + +/** + * The v2 event union is generated and enormous, so the fake events are built as + * plain objects and cast at this single boundary — the same trade the v1 adapter + * test makes for the generated client surface. + */ +const nativeEvent = (value: unknown): OpenCodeEvent => value as OpenCodeEvent; + +/** + * The fake event stream is a queue the test publishes into, opened by the one + * `server.connected` that releases `startSession` and never closed — so the + * pump settles into handling test events instead of its reconnect loop. + */ +const makeHarness = Effect.fn("makeOpenCode2AdapterHarness")(function* () { + const inbound = yield* Queue.unbounded(); + const canonicalEvents = yield* Queue.unbounded(); + const seen: ProviderRuntimeEvent[] = []; + + const client = { + session: { + create: () => Effect.succeed({ id: ROOT_SESSION_ID, location: { directory: process.cwd() } }), + environment: () => Effect.void, + switchAgent: () => Effect.void, + switchModel: () => Effect.void, + get: () => Effect.succeed({ id: ROOT_SESSION_ID, location: { directory: process.cwd() } }), + form: { list: () => Effect.succeed([]) }, + prompt: () => Effect.void, + active: () => Effect.succeed({}), + }, + permission: { list: () => Effect.succeed([]) }, + event: { + subscribe: () => + Stream.concat( + Stream.make(nativeEvent({ type: "server.connected" })), + Stream.fromQueue(inbound), + ), + }, + } as unknown as OpenCodeClient; + + const adapter: OpenCode2AdapterShape = yield* makeOpenCode2Adapter(OPENCODE2_SETTINGS, { + instanceId: PROVIDER_INSTANCE_ID, + environment: { SHARED_INSTANCE_VAR: "instance", T3CODE_THREAD_ID: "stale-thread" }, + }).pipe( + Effect.provide( + // NodeServices is outermost: ServerConfig resolves FileSystem and Path + // through it, and `mergeAll` alone would run the layers in parallel. + Layer.mergeAll( + Layer.succeed(OpenCode2Runtime, { + connect: () => + Effect.succeed({ + client, + url: "http://127.0.0.1:4096", + external: true, + version: "2.0.5", + }), + loadInventory: () => Effect.succeed({ models: [], skills: [] }), + }), + ServerConfig.layerTest(process.cwd(), process.cwd()), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ); + + yield* adapter.streamEvents.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + seen.push(event); + }).pipe(Effect.andThen(Queue.offer(canonicalEvents, event))), + ), + Effect.forkScoped({ startImmediately: true }), + ); + + const publish = (event: OpenCodeEvent) => Queue.offer(inbound, event).pipe(Effect.asVoid); + const waitForEvent = Effect.fn("OpenCode2AdapterTest.waitForEvent")(function* < + T extends ProviderRuntimeEvent, + >(predicate: (event: ProviderRuntimeEvent) => event is T) { + while (true) { + const event = yield* Queue.take(canonicalEvents); + if (predicate(event)) return event; + } + }); + + /** + * Starts a session and turn, then publishes the `session.created` that binds + * a subagent child and waits for its roster row. Every subagent test needs + * exactly this, and the receipt matters: child events are judged against the + * related-session set as the stream pulls, so the binding must land first. + */ + const openSubagent = Effect.fn("OpenCode2AdapterTest.openSubagent")(function* ( + name: string, + details?: { readonly description?: string; readonly agent?: string }, + ) { + const threadId = ThreadId.make(name); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const turn = yield* adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + yield* publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: details?.description ?? "Review the parser", + agent: details?.agent ?? "explore", + }, + }), + ); + yield* waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + return { threadId, turn, now }; + }); + + /** Marks the first run open, which the child lifecycle normally does. */ + const startChildRun = (created: number) => + publish( + nativeEvent({ + type: "session.execution.started", + created, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + const childStep = (created: number, assistantMessageID: string, output: number) => + nativeEvent({ + type: "session.step.ended", + created, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID, + finish: "stop", + cost: 0, + tokens: { input: 100, output, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }); + + return { + adapter, + publish, + seen, + waitForEvent, + openSubagent, + startChildRun, + childStep, + }; +}); + +const layer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-opencode2-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(layer)("OpenCode2Adapter", (it) => { + it.effect("keeps a subagent child session's transcript out of the parent turn", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const { turn, now } = yield* h.openSubagent("thread-subagent-content"); + + // The child's own transcript. None of it belongs to the parent turn. + yield* h.publish( + nativeEvent({ + type: "session.reasoning.delta", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + ordinal: 0, + delta: "child thinking", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.text.delta", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + ordinal: 1, + delta: "child answer", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.reasoning.ended", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + ordinal: 0, + text: "child thinking", + }, + }), + ); + + // Drain receipt: the last event published, so seeing it means every + // child event above was already handled. + yield* h.publish( + nativeEvent({ + type: "session.reasoning.delta", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_root", + ordinal: 1, + delta: "parent thinking", + }, + }), + ); + const parentDelta = yield* h.waitForEvent(isContentDeltaFor("msg_root:1")); + NodeAssert.equal(parentDelta.turnId, turn.turnId); + NodeAssert.equal(parentDelta.payload.streamKind, "reasoning_text"); + + const childTranscript = h.seen.filter((event) => event.itemId?.startsWith("msg_child")); + NodeAssert.deepEqual(childTranscript, []); + }), + ); + + it.effect("still routes a subagent child session's approval request to the parent", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-approval"); + + // A supervised mode keeps the ask in the parent UI instead of the + // adapter auto-replying to it. + yield* h.adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { sessionID: CHILD_SESSION_ID, parentID: ROOT_SESSION_ID }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + + yield* h.publish( + nativeEvent({ + type: "permission.asked", + created: now, + data: { + id: "per_child_approval", + sessionID: CHILD_SESSION_ID, + action: "bash", + resources: ["rm -rf /tmp/probe"], + }, + }), + ); + + const opened = yield* h.waitForEvent( + (event): event is RequestOpenedEvent => event.type === "request.opened", + ); + NodeAssert.equal(opened.requestId, "per_child_approval"); + }), + ); + + it.effect("surfaces a subagent on the Agents surface and settles it", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-roster"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + const turn = yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + + const started = yield* h.waitForEvent( + (event): event is TaskStartedEvent => event.type === "task.started", + ); + NodeAssert.equal(started.payload.taskId, CHILD_SESSION_ID); + NodeAssert.equal(started.payload.title, "Review the parser"); + NodeAssert.equal(started.payload.role, "explore"); + NodeAssert.equal(started.payload.timelineBypass, true); + NodeAssert.equal(started.turnId, turn.turnId); + + // The child's own execution lifecycle drives the roster row: busy opens + // it as running, and its terminal frame settles it. + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + const completed = yield* h.waitForEvent( + (event): event is TaskCompletedEvent => event.type === "task.completed", + ); + NodeAssert.equal(completed.payload.taskId, CHILD_SESSION_ID); + NodeAssert.equal(completed.payload.status, "completed"); + + // The child's terminal frame must never complete the parent turn. + const parentTurnEnded = h.seen.some((event) => event.type === "turn.completed"); + NodeAssert.equal(parentTurnEnded, false); + }), + ); + + it.effect("counts a subagent's own step tokens without charging the parent turn", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-usage"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + + // Two child steps: the accumulator must count each assistant message once. + const childStep = { + type: "session.step.ended", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child_step_1", + finish: "stop", + cost: 0, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } }, + }, + }; + yield* h.publish(nativeEvent(childStep)); + yield* h.publish( + nativeEvent({ + ...childStep, + data: { ...childStep.data, assistantMessageID: "msg_child_step_2" }, + }), + ); + // A duplicate frame for an already-counted message must not double-count. + yield* h.publish(nativeEvent(childStep)); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + const completed = yield* h.waitForEvent( + (event): event is TaskCompletedEvent => event.type === "task.completed", + ); + // input 100 + cache 30 + write 10 = 140 per step; output 20 + reasoning 5. + NodeAssert.deepEqual(completed.payload.typedUsage, { + totalTokens: 2 * (140 + 25), + inputTokens: 2 * 140, + cachedInputTokens: 2 * 30, + outputTokens: 2 * 25, + reasoningOutputTokens: 2 * 5, + }); + + // The child's tokens belong to the child, not the parent turn accumulator. + const parentTurnUsage = h.seen + .filter((event) => event.type === "thread.token-usage.updated") + .map((event) => event.payload.usage); + NodeAssert.deepEqual(parentTurnUsage, []); + }), + ); + + it.effect("counts a subagent's tokens up as it works, step by step", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-live-usage"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + // Two steps, so the count must be visible mid-run rather than only at + // the terminal row. + const step = (assistantMessageID: string, output: number) => ({ + type: "session.step.ended", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID, + finish: "stop", + cost: 0, + tokens: { input: 100, output, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }); + yield* h.publish(nativeEvent(step("msg_step_1", 20))); + yield* h.publish(nativeEvent(step("msg_step_2", 30))); + + const progress = yield* h.waitForEvent( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.typedUsage !== undefined, + ); + NodeAssert.equal(progress.payload.taskId, CHILD_SESSION_ID); + // The first tick lands after step one, before the run settles. + NodeAssert.equal(progress.payload.typedUsage?.totalTokens, 120); + + const ticks = h.seen.filter( + (event) => event.type === "task.progress" && event.payload.typedUsage !== undefined, + ); + // input 100 per step, output 20 then 30: 120, then 250 cumulative. + NodeAssert.deepEqual( + ticks.map((event) => + event.type === "task.progress" ? event.payload.typedUsage?.totalTokens : undefined, + ), + [120, 250], + ); + }), + ); + + it.effect("reopens a subagent when its session runs again", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-reactivation"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + // The spawn tool can reuse an existing child session for a later run, so + // the same session id settles and then starts again. + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.waitForEvent( + (event): event is TaskCompletedEvent => event.type === "task.completed", + ); + + // Second run on the same child session. + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + const reopened = yield* h.waitForEvent( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.status === "running", + ); + NodeAssert.equal(reopened.payload.taskId, CHILD_SESSION_ID); + }), + ); + + it.effect("keeps a reused child session running after its previous run settled", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-sleep"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Reasoning task two", + agent: "general", + }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + + // Run 1 completes. + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.waitForEvent( + (event): event is TaskCompletedEvent => event.type === "task.completed", + ); + + // Run 2 on the same session. Progress rows share one stable activity id, + // so a status-less row cannot reopen a settled agent: the shell activity + // must still mark it running. + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.tool.input.started", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_shell", + name: "shell", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.tool.called", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_shell", + input: { command: "sleep 15" }, + executed: true, + }, + }), + ); + + const shellRow = yield* h.waitForEvent( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.lastToolName === "shell", + ); + NodeAssert.equal(shellRow.payload.status, "running"); + + // Everything after the run opener must be able to reopen the agent: every + // activity row carries a running status. + const afterReopen = h.seen.slice( + h.seen.findIndex((event) => event.type === "task.completed") + 1, + ); + const statuslessProgress = afterReopen.filter( + (event) => event.type === "task.progress" && event.payload.status === undefined, + ); + NodeAssert.deepEqual(statuslessProgress, []); + }), + ); + + it.effect("shows a running subagent's current tool and counts its tool uses", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-activity"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + yield* h.waitForEvent((event): event is TaskStartedEvent => event.type === "task.started"); + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + // The live line alternates: a reasoning block labels it "Thinking", then + // the next tool replaces it. Both ride the progress summary the panel + // prefers over the sticky last tool name. + yield* h.publish( + nativeEvent({ + type: "session.reasoning.started", + created: now, + data: { sessionID: CHILD_SESSION_ID, assistantMessageID: "msg_child", ordinal: 0 }, + }), + ); + const thinking = yield* h.waitForEvent( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.summary === "Thinking", + ); + NodeAssert.equal(thinking.payload.taskId, CHILD_SESSION_ID); + + yield* h.publish( + nativeEvent({ + type: "session.tool.input.started", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_call_1", + name: "grep", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.tool.called", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_call_1", + input: {}, + executed: true, + }, + }), + ); + + const toolLine = yield* h.waitForEvent( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.lastToolName === "grep", + ); + NodeAssert.equal(toolLine.payload.summary, "▸ grep"); + // Without the running status this row could not reopen a settled agent + // on a reused child session. + NodeAssert.equal(toolLine.payload.status, "running"); + const thinkingRow = h.seen.find( + (event): event is TaskProgressEvent => + event.type === "task.progress" && event.payload.summary === "Thinking", + ); + NodeAssert.equal(thinkingRow?.payload.status, "running"); + + // Two completed child tools, then settle: the count must reach the card. + yield* h.publish( + nativeEvent({ + type: "session.tool.success", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_call_1", + executed: true, + content: [{ type: "text", text: "hit" }], + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.tool.success", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + id: "child_call_2", + executed: true, + content: [{ type: "text", text: "hit" }], + }, + }), + ); + + // The child's last assistant message becomes the settled row's outcome, + // so a completed agent reports what it produced instead of its last tool. + yield* h.publish( + nativeEvent({ + type: "session.text.ended", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + assistantMessageID: "msg_child", + ordinal: 0, + text: "The parser looks fine.", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.model.selected", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + model: { providerID: "anthropic", id: "claude-opus-5", variant: "high" }, + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + const completed = yield* h.waitForEvent( + (event): event is TaskCompletedEvent => event.type === "task.completed", + ); + NodeAssert.equal(completed.payload.typedUsage?.toolUses, 2); + NodeAssert.equal(completed.payload.summary, "The parser looks fine."); + NodeAssert.equal(completed.payload.model, "claude-opus-5"); + NodeAssert.equal(completed.payload.effort, "high"); + }), + ); + + it.effect("gives a background subagent's roster row the spawn tool call it replaces", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-linkage"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.tool.input.started", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_root", + id: "call_1", + name: "subagent", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + // Receipt: the stream filter judges child events against the related + // set as it pulls, so the binding must be applied before anything below + // is published. A root delta published after it proves that. + yield* h.publish( + nativeEvent({ + type: "session.reasoning.delta", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_warmup", + ordinal: 0, + delta: "warmup", + }, + }), + ); + yield* h.waitForEvent(isContentDeltaFor("msg_warmup:0")); + + yield* h.publish( + nativeEvent({ + type: "session.tool.success", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_root", + id: "call_1", + executed: true, + content: [ + { + type: "text", + text: "The subagent is working in the background (sessionID: ses_child). You will be notified automatically when it finishes.", + }, + ], + }, + }), + ); + // The child's lifecycle lands after its spawn result, and its progress + // row must carry the linked tool call so the chat fold can replace the + // launch row instead of showing both. + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + const progress = yield* h.waitForEvent( + (event): event is TaskProgressEvent => event.type === "task.progress", + ); + NodeAssert.equal(progress.payload.taskId, CHILD_SESSION_ID); + NodeAssert.equal(progress.payload.toolUseId, "call_1"); + }), + ); + + it.effect("produces a roster entry the Agents panel fold accepts", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const threadId = ThreadId.make("thread-subagent-panel"); + + yield* h.adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* h.adapter.sendTurn({ threadId, input: "spawn a subagent" }); + const now = yield* Clock.currentTimeMillis; + + yield* h.publish( + nativeEvent({ + type: "session.created", + created: now, + data: { + sessionID: CHILD_SESSION_ID, + parentID: ROOT_SESSION_ID, + title: "Review the parser", + agent: "explore", + }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.reasoning.delta", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_warmup", + ordinal: 0, + delta: "warmup", + }, + }), + ); + yield* h.waitForEvent(isContentDeltaFor("msg_warmup:0")); + yield* h.publish( + nativeEvent({ + type: "session.execution.started", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + yield* h.publish( + nativeEvent({ + type: "session.execution.succeeded", + created: now, + data: { sessionID: CHILD_SESSION_ID }, + }), + ); + + // Drain receipt: the last child event published, so every task row above + // has already been emitted once it arrives. + yield* h.publish( + nativeEvent({ + type: "session.reasoning.delta", + created: now, + data: { + sessionID: ROOT_SESSION_ID, + assistantMessageID: "msg_drain", + ordinal: 1, + delta: "drain", + }, + }), + ); + yield* h.waitForEvent(isContentDeltaFor("msg_drain:1")); + + // The Agents panel is a fold over persisted activities. Asserting the + // activities ingestion derives from the adapter's own events pins the + // contract the fold reads: a real agent task, with an identity and a + // terminal status, and no content leaking into the timeline. + const activities = h.seen.flatMap((event) => + runtimeEventToActivities(event).map((activity) => ({ + ...activity, + threadId, + turnId: activity.turnId ?? null, + })), + ); + const taskRows = activities.filter((activity) => activity.kind.startsWith("task.")); + NodeAssert.deepEqual( + taskRows.map((activity) => activity.kind), + ["task.started", "task.progress", "task.completed"], + ); + for (const row of taskRows) { + const payload = row.payload as Record; + NodeAssert.equal(payload.taskId, CHILD_SESSION_ID); + // The client fold only admits rows the server stamped as agents. + NodeAssert.equal(payload.agentKind, "agent"); + NodeAssert.equal(payload.title, "Review the parser"); + NodeAssert.equal(payload.role, "explore"); + NodeAssert.equal(payload.timelineBypass, true); + } + const completed = taskRows.at(-1)?.payload as Record; + NodeAssert.equal(completed.status, "completed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index bfbdd3b64e51..af4f3f61f517 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -10,6 +10,9 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, + type RuntimeTaskUsage, + type TaskAgentLinkage, type RuntimeMode, ThreadId, type ToolLifecycleItemType, @@ -341,6 +344,42 @@ interface OpenCode2IdleReconciliation { fiber?: Fiber.Fiber; } +/** + * A subagent child session the thread is tracking for the Agents surface. + * OpenCode emits no task frame of its own, and the roster is built from task + * rows, so the adapter synthesizes them from the child's own session. + */ +/** Where a subagent is in its current run. One value, not two booleans: an + * agent is either waiting for its first run, running, or settled. */ +type OpenCode2SubagentStatus = "idle" | "running" | "settled"; + +interface OpenCode2Subagent { + /** Child session id; also the runtime taskId (stable across every task.* row). */ + readonly taskId: string; + /** Spawning tool call, linked from the spawn tool's result when it arrives. */ + toolCallId: string | undefined; + readonly description: string | undefined; + readonly agent: string | undefined; + /** "provider/id" slug, shown beside the agent's role. */ + model: string | undefined; + /** Model variant, shown as the agent's effort. */ + effort: string | undefined; + /** The child's most recent assistant message, surfaced as its result. */ + lastText: string | undefined; + readonly turnId: TurnId | undefined; + /** Assistant message ids whose step tokens were already counted. */ + readonly usageStepIds: Set; + /** Cumulative tokens, mirroring the root turn accumulator. */ + totalTokens: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningTokens: number; + status: OpenCode2SubagentStatus; + /** Completed tool calls, surfaced as the panel's "N tools" figure. */ + toolUses: number; +} + interface OpenCode2SessionContext { session: ProviderSession; readonly client: OpenCodeClient; @@ -348,6 +387,8 @@ interface OpenCode2SessionContext { openCodeSessionId: string; /** Root session plus subagent child sessions, all routed to this thread. */ readonly relatedSessionIds: Set; + /** Child session id → tracked subagent, for the Agents surface. */ + readonly subagentsBySessionId: Map; readonly resolvedRequestIds: Set; readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; @@ -1525,6 +1566,336 @@ export function makeOpenCode2Adapter( } }; + // ── subagent roster ─────────────────────────────────────────── + // + // OpenCode runs a subagent as a child session created by its `subagent` + // tool. The child's transcript never reaches this thread (the content + // filter drops it), but the Agents surface still needs to show that an + // agent ran: it folds `task.*` rows, and OpenCode emits none. + // + // Rows are anchored on the child session and linked to the spawning tool + // call, so the chat fold replaces that call's row with the spawn CTA. + + /** The child session id on a `subagent` tool result, either form. */ + function subagentChildSessionIdFromToolResult(output: string): string | undefined { + // Background: "…(sessionID: ses_…)…". Foreground: ``. + const match = /(?:sessionID:\s*|sessionID=")([A-Za-z0-9_-]+)/.exec(output); + return match?.[1]; + } + + /** True when the agent has any usage figure worth reporting. */ + function subagentHasUsage(subagent: OpenCode2Subagent): boolean { + return subagent.totalTokens > 0 || subagent.toolUses > 0; + } + + function subagentTaskUsage(subagent: OpenCode2Subagent): RuntimeTaskUsage { + return { + totalTokens: subagent.totalTokens, + inputTokens: subagent.inputTokens, + cachedInputTokens: subagent.cachedInputTokens, + outputTokens: subagent.outputTokens, + reasoningOutputTokens: subagent.reasoningTokens, + ...(subagent.toolUses > 0 ? { toolUses: subagent.toolUses } : {}), + }; + } + + /** + * Identity repeated on every row, so a fold can rebuild the agent when its + * start row ages out of activity retention. `timelineBypass` keeps these + * rows out of the parent timeline; `toolUseId` lets the chat fold replace + * the launch tool row with the spawn CTA. + */ + function subagentLinkage(subagent: OpenCode2Subagent): TaskAgentLinkage { + return { + taskType: "local_agent", + timelineBypass: true, + ...(subagent.toolCallId ? { toolUseId: subagent.toolCallId } : {}), + ...(subagent.description ? { title: subagent.description } : {}), + ...(subagent.agent ? { role: subagent.agent } : {}), + ...(subagent.model ? { model: subagent.model } : {}), + ...(subagent.effort ? { effort: subagent.effort } : {}), + }; + } + + /** The label a row falls back to when the child gave no description. */ + function subagentLabel(subagent: OpenCode2Subagent): string { + return subagent.description ?? subagent.agent ?? "Subagent"; + } + + /** Usage and linkage, omitted entirely when the agent has reported none. */ + function subagentUsageFields(subagent: OpenCode2Subagent): { + typedUsage?: RuntimeTaskUsage; + } { + return subagentHasUsage(subagent) ? { typedUsage: subagentTaskUsage(subagent) } : {}; + } + + const subagentEventBase = ( + context: OpenCode2SessionContext, + subagent: OpenCode2Subagent, + event: OpenCodeEvent, + ) => + buildEventBase({ + threadId: context.session.threadId, + ...(subagent.turnId ? { turnId: subagent.turnId } : {}), + itemId: subagent.taskId, + raw: event, + }); + + /** Opens the roster row for a newly tracked subagent. */ + const emitSubagentStarted = ( + context: OpenCode2SessionContext, + subagent: OpenCode2Subagent, + event: OpenCodeEvent, + ) => + Effect.gen(function* () { + yield* emit({ + ...(yield* subagentEventBase(context, subagent, event)), + type: "task.started", + payload: { + taskId: RuntimeTaskId.make(subagent.taskId), + ...(subagent.description ? { description: subagent.description } : {}), + ...subagentLinkage(subagent), + }, + }); + }); + + /** + * A usage-only tick: the client applies this as latest state without + * reopening the run, so the count can climb mid-run. + */ + const emitSubagentUsage = ( + context: OpenCode2SessionContext, + subagent: OpenCode2Subagent, + event: OpenCodeEvent, + ) => + Effect.gen(function* () { + yield* emit({ + ...(yield* subagentEventBase(context, subagent, event)), + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(subagent.taskId), + description: subagentLabel(subagent), + typedUsage: subagentTaskUsage(subagent), + ...subagentLinkage(subagent), + }, + }); + }); + + /** + * The live activity line. `summary` supersedes `lastToolName` in the panel, + * so each activity replaces the previous one instead of letting a stale + * tool name stick for the rest of the run. The running status is what + * reopens a settled agent on a reused session. + */ + const emitSubagentProgress = ( + context: OpenCode2SessionContext, + subagent: OpenCode2Subagent, + activity: { readonly summary: string; readonly toolName?: string }, + event: OpenCodeEvent, + ) => + Effect.gen(function* () { + yield* emit({ + ...(yield* subagentEventBase(context, subagent, event)), + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(subagent.taskId), + description: subagentLabel(subagent), + status: "running", + summary: activity.summary, + ...(activity.toolName ? { lastToolName: activity.toolName } : {}), + ...subagentUsageFields(subagent), + ...subagentLinkage(subagent), + }, + }); + }); + + /** Closes the run. The child's last message is its outcome, so a settled + * row reports what it produced rather than the last tool it ran. */ + const emitSubagentSettled = ( + context: OpenCode2SessionContext, + subagent: OpenCode2Subagent, + outcome: { readonly status: "completed" | "failed" | "stopped"; readonly error?: string }, + event: OpenCodeEvent, + ) => + Effect.gen(function* () { + const summary = outcome.error ?? subagent.lastText; + yield* emit({ + ...(yield* subagentEventBase(context, subagent, event)), + type: "task.completed", + payload: { + taskId: RuntimeTaskId.make(subagent.taskId), + status: outcome.status, + ...(summary ? { summary } : {}), + ...subagentUsageFields(subagent), + ...subagentLinkage(subagent), + }, + }); + }); + + /** Registers a child session as a subagent and opens its roster row. */ + const trackSubagent = Effect.fn("opencode2.trackSubagent")(function* ( + context: OpenCode2SessionContext, + sessionId: string, + details: { + readonly description?: string | undefined; + readonly agent?: string | undefined; + readonly model?: string | undefined; + readonly effort?: string | undefined; + }, + event: OpenCodeEvent, + ) { + if (context.subagentsBySessionId.has(sessionId)) return; + const subagent: OpenCode2Subagent = { + taskId: sessionId, + toolCallId: undefined, + description: details.description, + agent: details.agent, + model: details.model, + effort: details.effort, + lastText: undefined, + turnId: context.activeTurnId, + usageStepIds: new Set(), + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + // The child's execution start opens the first run. + status: "idle", + toolUses: 0, + }; + context.subagentsBySessionId.set(sessionId, subagent); + yield* emitSubagentStarted(context, subagent, event); + }); + + /** Settles one tracked subagent; the first terminal row wins. */ + const settleSubagent = ( + context: OpenCode2SessionContext, + sessionId: string, + outcome: { + readonly status: "completed" | "failed" | "stopped"; + readonly error?: string; + }, + event: OpenCodeEvent, + ) => + Effect.gen(function* () { + const subagent = context.subagentsBySessionId.get(sessionId); + if (subagent === undefined || subagent.status === "settled") return; + subagent.status = "settled"; + yield* emitSubagentSettled(context, subagent, outcome, event); + }); + + /** + * Child sessions run their own execution lifecycle, which never maps onto + * the parent turn. It only drives the roster row; a child's terminal frame + * must never complete the parent turn. + */ + const handleSubagentLifecycleEvent = Effect.fn("opencode2.handleSubagentLifecycleEvent")( + function* (context: OpenCode2SessionContext, sessionId: string, event: OpenCodeEvent) { + const subagent = context.subagentsBySessionId.get(sessionId); + switch (event.type) { + case "session.reasoning.started": { + if (subagent?.status !== "running") return; + yield* emitSubagentProgress(context, subagent, { summary: "Thinking" }, event); + return; + } + case "session.model.selected": { + // The resolved model can differ from what the spawn call requested. + if (subagent === undefined) return; + subagent.model = event.data.model.id; + subagent.effort = event.data.model.variant; + return; + } + case "session.text.ended": { + if (subagent === undefined) return; + const text = trimText(event.data.text); + if (text !== undefined) subagent.lastText = text; + return; + } + case "session.tool.input.started": { + // Child tools are routed here, so the root switch never fills the + // shared name map for them; the called frame carries only the id. + context.toolNamesById.set(event.data.id, event.data.name); + return; + } + case "session.tool.called": { + if (subagent?.status !== "running") return; + const toolName = context.toolNamesById.get(event.data.id); + if (toolName === undefined) return; + yield* emitSubagentProgress( + context, + subagent, + { summary: `▸ ${toolName}`, toolName }, + event, + ); + return; + } + case "session.tool.success": { + if (subagent === undefined) return; + subagent.toolUses += 1; + yield* emitSubagentUsage(context, subagent, event); + return; + } + case "session.step.ended": { + // The child's own step tokens are the only per-agent usage source, + // and these boundaries are the finest granularity OpenCode exposes: + // `session.usage.updated` is emitted from them and is cumulative. + // They must never reach the root accumulator, whose scope is + // main_agent — hence the split routing. + if (subagent === undefined) return; + const tokens = event.data.tokens; + if (subagent.usageStepIds.has(event.data.assistantMessageID)) return; + subagent.usageStepIds.add(event.data.assistantMessageID); + subagent.inputTokens += tokens.input + tokens.cache.read + tokens.cache.write; + subagent.cachedInputTokens += tokens.cache.read; + subagent.outputTokens += tokens.output + tokens.reasoning; + subagent.reasoningTokens += tokens.reasoning; + // Cache reads/writes are already part of `inputTokens`. + subagent.totalTokens = subagent.inputTokens + subagent.outputTokens; + yield* emitSubagentUsage(context, subagent, event); + return; + } + case "session.execution.started": + case "session.status": { + if (event.type === "session.status" && event.data.status.type !== "busy") return; + if (subagent === undefined) return; + // The spawn tool may reuse an existing child session for a later + // run, so a settled agent reopens here. Idempotent: a status burst + // must not multiply running rows. + if (subagent.status === "running") return; + // A new run must not inherit the previous run's outcome. + subagent.lastText = undefined; + subagent.status = "running"; + yield* emitSubagentProgress(context, subagent, { summary: "Working" }, event); + return; + } + case "session.execution.succeeded": + case "session.idle": { + yield* settleSubagent(context, sessionId, { status: "completed" }, event); + return; + } + case "session.execution.failed": { + yield* settleSubagent( + context, + sessionId, + { + status: "failed", + error: trimText(event.data.error.message) ?? "Subagent failed.", + }, + event, + ); + return; + } + case "session.execution.interrupted": { + yield* settleSubagent(context, sessionId, { status: "stopped" }, event); + return; + } + default: + return; + } + }, + ); + const handleSubscribedEvent = (context: OpenCode2SessionContext, event: OpenCodeEvent) => Effect.gen(function* () { if (event.type === "server.connected") { @@ -1554,26 +1925,49 @@ export function makeOpenCode2Adapter( } const turnId = context.activeTurnId; - // Execution lifecycle is per session, and subagent child sessions run - // their own; only the root session's run maps onto the T3 turn. - const isRootExecutionEvent = - !event.type.startsWith("session.execution.") || sessionId === context.openCodeSessionId; - if (!isRootExecutionEvent) { - return; - } - switch (event.type) { - case "session.created": { + // A related subagent child session is routed here only to bind it, to + // surface its approvals, and to drive its roster row. Its transcript — + // text, reasoning, tools — must never be projected into this turn. + if (sessionId !== undefined && sessionId !== context.openCodeSessionId) { + if (isRequestBearingEvent(event)) { + // Fall through: approvals belong to the parent UI. + } else if (event.type === "session.created") { + // Only a child of a session already bound to this thread is a + // subagent; the shared server hosts unrelated sessions. if (event.data.parentID && context.relatedSessionIds.has(event.data.parentID)) { - addRelatedSession(context, event.data.sessionID); + addRelatedSession(context, sessionId); + const model = event.data.model; + yield* trackSubagent( + context, + sessionId, + { + description: trimText(event.data.title), + agent: event.data.agent, + model: model?.id, + effort: model?.variant, + }, + event, + ); } - break; - } - case "session.forked": { + return; + } else if (event.type === "session.forked") { + // A fork continues this thread's own session (rollback): bind it so + // its later events route, but keep it off the roster. if (context.relatedSessionIds.has(event.data.parentID)) { - addRelatedSession(context, event.data.sessionID); + addRelatedSession(context, sessionId); } - break; + return; + } else if (event.type === "session.deleted") { + context.relatedSessionIds.delete(sessionId); + yield* settleSubagent(context, sessionId, { status: "stopped" }, event); + context.subagentsBySessionId.delete(sessionId); + return; + } else { + yield* handleSubagentLifecycleEvent(context, sessionId, event); + return; } + } + switch (event.type) { case "session.deleted": { if (event.data.sessionID === context.openCodeSessionId) { // The root session is gone from the shared server, so no later @@ -1876,6 +2270,14 @@ export function makeOpenCode2Adapter( .filter((part) => part.type === "text") .map((part) => part.text) .join("\n"); + // A `subagent` call's result reports the child session it created; + // linking it lets the child's own lifecycle drive the roster row + // and the chat fold replace this call with the spawn CTA. + const childSessionId = subagentChildSessionIdFromToolResult(output); + if (childSessionId !== undefined) { + const subagent = context.subagentsBySessionId.get(childSessionId); + if (subagent !== undefined) subagent.toolCallId = event.data.id; + } const itemType = toOpenCode2ToolItemType(toolName); yield* emit({ ...(yield* buildEventBase({ @@ -2478,6 +2880,7 @@ export function makeOpenCode2Adapter( directory, openCodeSessionId: sessionInfo.id, relatedSessionIds: new Set([sessionInfo.id]), + subagentsBySessionId: new Map(), resolvedRequestIds: new Set(), autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), From 92a174d589fb085e0c2dea1aab6b76d2bb563590 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 18 Sep 2026 17:39:50 +0300 Subject: [PATCH 12/17] chore(server): update OpenCode 2 client to 2.0.8 Ships the 2.0.8 client and schema. Two API changes needed adapting: - `server.status()` became `server.info()`, and the probe endpoint moved from `/api/status` to `/api/info`. The response now also carries `paths`, which the generated client's schema requires. - The dist patch for the extensionless ESM imports still applies and covers the same three files, so it is renamed to the new version. Its last hunk was missing a trailing newline, which made the tooling treat it as corrupt. Pointed the probe tests at `/api/info` and the new response shape. --- apps/server/package.json | 4 +-- .../provider/Layers/OpenCode2Adapter.test.ts | 2 +- .../src/provider/opencode2Runtime.test.ts | 16 ++++++--- apps/server/src/provider/opencode2Runtime.ts | 6 ++-- ....5.patch => @opencode__client@2.0.8.patch} | 2 +- pnpm-lock.yaml | 34 +++++++++---------- pnpm-workspace.yaml | 8 ++--- 7 files changed, 39 insertions(+), 33 deletions(-) rename patches/{@opencode__client@2.0.5.patch => @opencode__client@2.0.8.patch} (93%) diff --git a/apps/server/package.json b/apps/server/package.json index 0d339e894ac2..8683af4e23e1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,8 +28,8 @@ "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", - "@opencode/client": "2.0.5", - "@opencode/schema": "2.0.5", + "@opencode/client": "2.0.8", + "@opencode/schema": "2.0.8", "diff": "8.0.3", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts index 45655af6027f..bec0fe3b88c3 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts @@ -86,7 +86,7 @@ const makeHarness = Effect.fn("makeOpenCode2AdapterHarness")(function* () { client, url: "http://127.0.0.1:4096", external: true, - version: "2.0.5", + version: "2.0.8", }), loadInventory: () => Effect.succeed({ models: [], skills: [] }), }), diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts index 6ce78d1ec2ed..51bede07243f 100644 --- a/apps/server/src/provider/opencode2Runtime.test.ts +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -20,13 +20,19 @@ import { withOpenCode2Variant, } from "./opencode2Runtime.ts"; -const HEALTH_BODY = { version: "2.0.5", pid: 1234, urls: [] as Array }; +// `server.info` (2.0.8) reports the server paths alongside the version. +const HEALTH_BODY = { + version: "2.0.8", + pid: 1234, + urls: [] as Array, + paths: { tmp: "/tmp/opencode" }, +}; /** * Records every request URL while answering the health probe. Recording the * request is the point: the external-server path used to normalize the * configured URL with `url.parse(...).toString()`, which evaluates to the - * string "[object Object]" — the probe then requested "[object Object]/api/status" + * string "[object Object]" — the probe then requested "[object Object]/api/info" * and every external server looked unreachable. Asserting on the runtime's own * arguments would not have caught that. */ @@ -65,8 +71,8 @@ it.effect("probes an external server at its configured URL", () => NodeAssert.equal(exit._tag, "Success"); if (exit._tag !== "Success") return; NodeAssert.equal(exit.value.url, "http://127.0.0.1:49374"); - NodeAssert.equal(exit.value.version, "2.0.5"); - NodeAssert.deepEqual(requestedUrls, ["http://127.0.0.1:49374/api/status"]); + NodeAssert.equal(exit.value.version, "2.0.8"); + NodeAssert.deepEqual(requestedUrls, ["http://127.0.0.1:49374/api/info"]); }), ); @@ -75,7 +81,7 @@ it.effect("keeps a trailing slash from corrupting the probe URL", () => const { requestedUrls } = yield* connectWithRequestLog("http://127.0.0.1:49374/"); NodeAssert.equal(requestedUrls.length, 1); - NodeAssert.ok(requestedUrls[0]?.includes("127.0.0.1:49374/api/status")); + NodeAssert.ok(requestedUrls[0]?.includes("127.0.0.1:49374/api/info")); NodeAssert.ok(!requestedUrls[0]?.includes("[object Object]")); }), ); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 2139a74953ce..ef480bf403fb 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -25,7 +25,7 @@ export interface OpenCode2Connection { readonly client: OpenCodeClient; readonly url: string; readonly external: boolean; - /** Server version from `server.status`, resolved at connect time. */ + /** Server version from `server.info`, resolved at connect time. */ readonly version: string; } @@ -224,12 +224,12 @@ const makeOpenCode2Runtime = Effect.gen(function* () { ); /** - * `server.status` is the connection's liveness + version probe in v2. A v1 + * `server.info` is the connection's liveness + version probe in v2. A v1 * server does not serve it, so adopting one fails here with "did not * respond"; the version gate below stays as a second line of defense. */ const probeConnection = (client: OpenCodeClient) => - client.server.status().pipe( + client.server.info().pipe( Effect.timeout(OPENCODE2_CONNECT_TIMEOUT), Effect.mapError((cause) => ensureRuntimeError( diff --git a/patches/@opencode__client@2.0.5.patch b/patches/@opencode__client@2.0.8.patch similarity index 93% rename from patches/@opencode__client@2.0.5.patch rename to patches/@opencode__client@2.0.8.patch index 24de07a398b8..f01fe75c1e0a 100644 --- a/patches/@opencode__client@2.0.5.patch +++ b/patches/@opencode__client@2.0.8.patch @@ -15,4 +15,4 @@ diff --git a/dist/solid/data.js b/dist/solid/data.js +++ b/dist/solid/data.js @@ -8 +8 @@ -import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise"; -+import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise/index.js"; \ No newline at end of file ++import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise/index.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1a6b1ab0aff..02dc0ce187fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,7 +94,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 - '@opencode/client@2.0.5': ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2 + '@opencode/client@2.0.8': c83b0d4f56e7a296891e2e7a6e49a0e240fa64df390d864c36658848de2b340a '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -518,11 +518,11 @@ importers: specifier: ^1.3.15 version: 1.15.13 '@opencode/client': - specifier: 2.0.5 - version: 2.0.5(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) + specifier: 2.0.8 + version: 2.0.8(patch_hash=c83b0d4f56e7a296891e2e7a6e49a0e240fa64df390d864c36658848de2b340a)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) '@opencode/schema': - specifier: 2.0.5 - version: 2.0.5 + specifier: 2.0.8 + version: 2.0.8 diff: specifier: 8.0.3 version: 8.0.3 @@ -3448,8 +3448,8 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} - '@opencode/client@2.0.5': - resolution: {integrity: sha512-+eCp8XOzONSLDdCRCKJfD7JO8ofTxMyBhuuskgAETFDw0HSgHY40TDmcAZqWh6W9pMDElGJv9L1mDaufPPp6AQ==} + '@opencode/client@2.0.8': + resolution: {integrity: sha512-JHyDdDkiKOtR/Psmai+CO+w0KQ/iObcVIYQpG2iYRrGDBOQDpK4Y/BO5tvo6TBqd4ztNYSYGrpn+ftDsf8Gvzg==} peerDependencies: effect: 4.0.0-rc.115 solid-js: '>=1.9.0' @@ -3459,11 +3459,11 @@ packages: solid-js: optional: true - '@opencode/protocol@2.0.5': - resolution: {integrity: sha512-RUuDNN87/ZK8nuPQnAhlyQ6szt+xllgQFk4MIhsU0W1Oez1C7b7w8Sv+p7xnylLfnEHVlz108MTeJTsmbNQKLw==} + '@opencode/protocol@2.0.8': + resolution: {integrity: sha512-X9aCZP91brEPFig5CInMzm4HUcC/dfMvI4IOF3OCY9xBk149oGA8PFKtb+TS/OFV1J37Wp+Pwb6HBANL37pMZQ==} - '@opencode/schema@2.0.5': - resolution: {integrity: sha512-Cf6HyFDROTjipoCYFkv+6RoKGMSXZEgbjAvMBy2teUtAluP1APcCIvqDGutueUdqAlzqIeaWHwJTxLXbyKAq5w==} + '@opencode/schema@2.0.8': + resolution: {integrity: sha512-nZdh0zA1AGUT2Mp9s0zuWpIcQkBL6zuz52EY80Yxfk3VPq9UgMaJb3Y5bJUSaxsw6BijJm8/xosWdEz1+1votA==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -13836,19 +13836,19 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@opencode/client@2.0.5(patch_hash=ddee0c183e5362990e9b13fb04d4c53f2688037ca4e92946478b70e345262ea2)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))': + '@opencode/client@2.0.8(patch_hash=c83b0d4f56e7a296891e2e7a6e49a0e240fa64df390d864c36658848de2b340a)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))': dependencies: - '@opencode/protocol': 2.0.5 - '@opencode/schema': 2.0.5 + '@opencode/protocol': 2.0.8 + '@opencode/schema': 2.0.8 optionalDependencies: effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) - '@opencode/protocol@2.0.5': + '@opencode/protocol@2.0.8': dependencies: - '@opencode/schema': 2.0.5 + '@opencode/schema': 2.0.8 effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) - '@opencode/schema@2.0.5': + '@opencode/schema@2.0.8': dependencies: '@standard-schema/spec': 1.1.0 effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 532282d2a8b5..1d95d3a56125 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,9 +95,9 @@ minimumReleaseAgeExclude: - "@distilled.cloud/prisma@1.0.0-rc.12" - "@distilled.cloud/railway@1.0.0-rc.12" - "@distilled.cloud/stripe@1.0.0-rc.12" - - "@opencode/client@2.0.5" - - "@opencode/protocol@2.0.5" - - "@opencode/schema@2.0.5" + - "@opencode/client@2.0.8" + - "@opencode/protocol@2.0.8" + - "@opencode/schema@2.0.8" overrides: # Earlier React Navigation declarations create a circular type alias in TypeScript 7. @@ -183,7 +183,7 @@ patchedDependencies: "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch - "@opencode/client@2.0.5": patches/@opencode__client@2.0.5.patch + "@opencode/client@2.0.8": patches/@opencode__client@2.0.8.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch From eb8644c6095a9c0cab77f2c2388f3cc34f11f26f Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 18 Sep 2026 18:01:02 +0300 Subject: [PATCH 13/17] fix(server): harden OpenCode 2 reconnect recovery --- .../src/provider/Drivers/OpenCode2Driver.ts | 22 ++- .../src/provider/Layers/OpenCode2Adapter.ts | 173 +++++++++++------- 2 files changed, 123 insertions(+), 72 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts index 3e2c8905aa3f..39d538229aa1 100644 --- a/apps/server/src/provider/Drivers/OpenCode2Driver.ts +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -95,10 +95,12 @@ export const OpenCode2Driver: ProviderDriver + checkOpenCode2ProviderStatus(effectiveConfig, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCode2Runtime, openCode2Runtime), + ); + const checkProvider = checkProviderForCwd(serverConfig.cwd); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider< @@ -133,6 +135,18 @@ export const OpenCode2Driver: ProviderDriver + checkProviderForCwd(cwd).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode 2 for '${cwd}'`, + cause, + }), + ), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index af4f3f61f517..8e96a42c2f00 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -366,7 +366,7 @@ interface OpenCode2Subagent { effort: string | undefined; /** The child's most recent assistant message, surfaced as its result. */ lastText: string | undefined; - readonly turnId: TurnId | undefined; + turnId: TurnId | undefined; /** Assistant message ids whose step tokens were already counted. */ readonly usageStepIds: Set; /** Cumulative tokens, mirroring the root turn accumulator. */ @@ -1632,20 +1632,20 @@ export function makeOpenCode2Adapter( const subagentEventBase = ( context: OpenCode2SessionContext, subagent: OpenCode2Subagent, - event: OpenCodeEvent, + event: OpenCodeEvent | undefined, ) => buildEventBase({ threadId: context.session.threadId, ...(subagent.turnId ? { turnId: subagent.turnId } : {}), itemId: subagent.taskId, - raw: event, + ...(event ? { raw: event } : {}), }); /** Opens the roster row for a newly tracked subagent. */ const emitSubagentStarted = ( context: OpenCode2SessionContext, subagent: OpenCode2Subagent, - event: OpenCodeEvent, + event: OpenCodeEvent | undefined, ) => Effect.gen(function* () { yield* emit({ @@ -1742,7 +1742,7 @@ export function makeOpenCode2Adapter( readonly model?: string | undefined; readonly effort?: string | undefined; }, - event: OpenCodeEvent, + event?: OpenCodeEvent, ) { if (context.subagentsBySessionId.has(sessionId)) return; const subagent: OpenCode2Subagent = { @@ -1864,6 +1864,7 @@ export function makeOpenCode2Adapter( // must not multiply running rows. if (subagent.status === "running") return; // A new run must not inherit the previous run's outcome. + subagent.turnId = context.activeTurnId; subagent.lastText = undefined; subagent.status = "running"; yield* emitSubagentProgress(context, subagent, { summary: "Working" }, event); @@ -2463,6 +2464,40 @@ export function makeOpenCode2Adapter( }) .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); } + // The stream may have missed child creation events while it was down. + // Rebuild the related-session set from the server's session inventory so + // later child lifecycle events are not filtered as unrelated traffic. + const sessions = yield* context.client.session + .list({ directory: toDirectory(context.directory) }) + .pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => undefined), + ); + if (sessions !== undefined) { + const byId = new Map(sessions.data.map((session) => [session.id, session])); + let changed = true; + while (changed) { + changed = false; + for (const session of byId.values()) { + if ( + session.parentID !== undefined && + context.relatedSessionIds.has(session.parentID) && + !context.relatedSessionIds.has(session.id) + ) { + addRelatedSession(context, session.id); + if (session.fork === undefined) { + yield* trackSubagent(context, session.id, { + description: trimText(session.title), + agent: session.agent, + model: session.model?.id, + effort: session.model?.variant, + }); + } + changed = true; + } + } + } + } // A restarted server has forgotten the thread's MCP registration too; // `mcp.add` is a plain upsert, so re-sending it is safe when it has not. yield* registerThreadMcp(context).pipe(Effect.ignoreCause); @@ -2526,7 +2561,12 @@ export function makeOpenCode2Adapter( // (re)connect needs no extra guard, and it will not override a // failure already recorded by `emitUnexpectedExit`. event.type === "server.connected" - ? Deferred.succeed(context.firstConnection, undefined).pipe(Effect.ignore) + ? Effect.all([ + Deferred.succeed(context.firstConnection, undefined).pipe(Effect.ignore), + Effect.sync(() => { + attempt = 0; + }), + ]).pipe(Effect.asVoid) : Effect.void, ), Stream.runForEach((event) => handleSubscribedEvent(context, event)), @@ -2556,7 +2596,7 @@ export function makeOpenCode2Adapter( if (reconciled === "gone") { return; } - attempt = reconciled === "alive" ? 0 : attempt + 1; + attempt = reconciled === "alive" ? attempt : attempt + 1; } }).pipe(Effect.ignoreCause); yield* run.pipe(Effect.forkIn(context.sessionScope)); @@ -2941,70 +2981,67 @@ export function makeOpenCode2Adapter( yield* releaseContext(context, { interruptRemote: created }); }).pipe(Effect.ensuring(Effect.sync(() => deleteContextIfCurrent(context)))); - // Session environment (v2's analog of the spawned-server env merge). - // `options.environment` is already the merged instance env; the start - // input carries no per-session env, so there is nothing further to fold - // in here. - const environmentVariables = Object.fromEntries( - Object.entries(options?.environment ?? {}).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - if (Object.keys(environmentVariables).length > 0) { - yield* context.client.session - .environment({ - sessionID: toSessionId(context.openCodeSessionId), - variables: environmentVariables, - }) - .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); - } + const startupExit = yield* Effect.exit( + Effect.gen(function* () { + // Session environment (v2's analog of the spawned-server env + // merge). `options.environment` is already the merged instance + // env; the start input carries no per-session env. + const environmentVariables = Object.fromEntries( + Object.entries(options?.environment ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + if (Object.keys(environmentVariables).length > 0) { + yield* context.client.session + .environment({ + sessionID: toSessionId(context.openCodeSessionId), + variables: environmentVariables, + }) + .pipe(Effect.timeout("5 seconds"), Effect.ignoreCause); + } - // Registration failure must not kill the session, but a silent loss of - // the whole t3-code toolkit is the kind of thing users notice hours - // later — surface it as a runtime warning instead. - yield* registerThreadMcp(context).pipe( - Effect.catch((cause) => - Effect.gen(function* () { - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "runtime.warning", - payload: { - message: - "Failed to register the T3 Code MCP server; agents will not see t3-code tools this session.", - }, - }); - void cause; - }), - ), - ); - const connectionExit = yield* Effect.gen(function* () { - yield* startEventPump(context); - // Wait for a live subscription, bounded so an unreachable-but- - // answering server fails session start rather than hanging it. - yield* Deferred.await(context.firstConnection).pipe( - Effect.timeoutOrElse({ - duration: OPENCODE2_STREAM_READY_TIMEOUT, - orElse: () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "event.subscribe", - detail: `OpenCode 2 event stream did not connect within ${OPENCODE2_STREAM_READY_TIMEOUT}.`, - }), - ), - }), - ); - }).pipe( - Effect.onInterrupt(() => cleanupStartingContext), - Effect.exit, + // Registration failure must not kill the session, but a silent + // loss of the whole t3-code toolkit should be visible to the user. + yield* registerThreadMcp(context).pipe( + Effect.catch((cause) => + Effect.gen(function* () { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "runtime.warning", + payload: { + message: + "Failed to register the T3 Code MCP server; agents will not see t3-code tools this session.", + }, + }); + void cause; + }), + ), + ); + yield* startEventPump(context); + // Wait for a live subscription, bounded so an unreachable-but- + // answering server fails session start rather than hanging it. + yield* Deferred.await(context.firstConnection).pipe( + Effect.timeoutOrElse({ + duration: OPENCODE2_STREAM_READY_TIMEOUT, + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: `OpenCode 2 event stream did not connect within ${OPENCODE2_STREAM_READY_TIMEOUT}.`, + }), + ), + }), + ); + yield* awaitContextReady(context); + if (!created) { + yield* recoverPendingRequests(context); + } + }).pipe(Effect.onInterrupt(() => cleanupStartingContext)), ); - if (Exit.isFailure(connectionExit)) { + if (Exit.isFailure(startupExit)) { yield* cleanupStartingContext; - return yield* Effect.failCause(connectionExit.cause); - } - yield* awaitContextReady(context); - if (!created) { - yield* recoverPendingRequests(context); + return yield* Effect.failCause(startupExit.cause); } yield* emit({ From 4d644d6ce706485391ff029a7fe60c8b08338679 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 18 Sep 2026 18:59:18 +0300 Subject: [PATCH 14/17] style(server): use opencode2 runtime namespace --- .../src/provider/Drivers/OpenCode2Driver.ts | 8 ++-- .../src/provider/Layers/OpenCode2Adapter.ts | 37 ++++++++----------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts index 39d538229aa1..43ad43766e14 100644 --- a/apps/server/src/provider/Drivers/OpenCode2Driver.ts +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -27,7 +27,7 @@ import { makePendingOpenCode2Provider, } from "../Layers/OpenCode2Provider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCode2Runtime } from "../opencode2Runtime.ts"; +import * as OpenCode2Runtime from "../opencode2Runtime.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -56,7 +56,7 @@ export type OpenCode2DriverEnv = | BackgroundPolicy.BackgroundPolicy | Crypto.Crypto | FileSystem.FileSystem - | OpenCode2Runtime + | OpenCode2Runtime.OpenCode2Runtime | Path.Path | ServerConfig | ServerSettingsService; @@ -72,7 +72,7 @@ export const OpenCode2Driver: ProviderDriver Effect.gen(function* () { const serverConfig = yield* ServerConfig; - const openCode2Runtime = yield* OpenCode2Runtime; + const openCode2Runtime = yield* OpenCode2Runtime.OpenCode2Runtime; const serverSettings = yield* ServerSettingsService; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -98,7 +98,7 @@ export const OpenCode2Driver: ProviderDriver checkOpenCode2ProviderStatus(effectiveConfig, cwd).pipe( Effect.map(stampIdentity), - Effect.provideService(OpenCode2Runtime, openCode2Runtime), + Effect.provideService(OpenCode2Runtime.OpenCode2Runtime, openCode2Runtime), ); const checkProvider = checkProviderForCwd(serverConfig.cwd); diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 8e96a42c2f00..939ed84a631f 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -61,14 +61,6 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type OpenCode2AdapterShape } from "../Services/OpenCode2Adapter.ts"; -import { - buildOpenCode2SessionRules, - openCode2McpServerName, - parseOpenCode2ModelSlug, - toOpenCode2FileParts, - withOpenCode2Variant, -} from "../opencode2Runtime.ts"; -import type { OpenCode2Connection, OpenCodeClient } from "../opencode2Runtime.ts"; import * as OpenCode2Runtime from "../opencode2Runtime.ts"; const PROVIDER = ProviderDriverKind.make("opencode2"); @@ -382,7 +374,7 @@ interface OpenCode2Subagent { interface OpenCode2SessionContext { session: ProviderSession; - readonly client: OpenCodeClient; + readonly client: OpenCode2Runtime.OpenCodeClient; readonly directory: string; openCodeSessionId: string; /** Root session plus subagent child sessions, all routed to this thread. */ @@ -458,8 +450,11 @@ export function makeOpenCode2Adapter( // One connection per adapter: settings are fixed at construction, so the // health probe runs once per adapter instead of once per session start. - let cachedConnection: OpenCode2Connection | undefined; - const connect = (): Effect.Effect => + let cachedConnection: OpenCode2Runtime.OpenCode2Connection | undefined; + const connect = (): Effect.Effect< + OpenCode2Runtime.OpenCode2Connection, + ProviderAdapterRequestError + > => Effect.gen(function* () { if (cachedConnection) { return cachedConnection; @@ -2453,7 +2448,7 @@ export function makeOpenCode2Adapter( // case the shared server restarted with different config. Sent even when // empty so a session that moved to full-access gets its rules cleared. if (context.appliedRulesMode) { - const ruleset = buildOpenCode2SessionRules({ + const ruleset = OpenCode2Runtime.buildOpenCode2SessionRules({ runtimeMode: context.appliedRulesMode, ownMcpServerName: context.mcpServerName, }); @@ -2609,7 +2604,7 @@ export function makeOpenCode2Adapter( runtimeMode: RuntimeMode, ): Effect.Effect => Effect.gen(function* () { - const ruleset = buildOpenCode2SessionRules({ + const ruleset = OpenCode2Runtime.buildOpenCode2SessionRules({ runtimeMode, ownMcpServerName: context.mcpServerName, }); @@ -2773,7 +2768,7 @@ export function makeOpenCode2Adapter( } const sessionScope = yield* Scope.make(); - const mcpServerName = openCode2McpServerName( + const mcpServerName = OpenCode2Runtime.openCode2McpServerName( openCode2Settings.mcpServerName, input.threadId, ); @@ -2781,13 +2776,13 @@ export function makeOpenCode2Adapter( Effect.gen(function* () { const connection = yield* connect(); const client = connection.client; - const ruleset = buildOpenCode2SessionRules({ + const ruleset = OpenCode2Runtime.buildOpenCode2SessionRules({ runtimeMode: input.runtimeMode, ownMcpServerName: mcpServerName, }); const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const parsedModel = withOpenCode2Variant( - parseOpenCode2ModelSlug(input.modelSelection?.model), + const parsedModel = OpenCode2Runtime.withOpenCode2Variant( + OpenCode2Runtime.parseOpenCode2ModelSlug(input.modelSelection?.model), getModelSelectionStringOptionValue(input.modelSelection, "variant"), ); @@ -3078,8 +3073,8 @@ export function makeOpenCode2Adapter( issue: `OpenCode 2 model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`, }); } - const parsedModel = withOpenCode2Variant( - parseOpenCode2ModelSlug(modelSelection?.model), + const parsedModel = OpenCode2Runtime.withOpenCode2Variant( + OpenCode2Runtime.parseOpenCode2ModelSlug(modelSelection?.model), getModelSelectionStringOptionValue(modelSelection, "variant"), ); if (modelSelection !== undefined && modelSelection.model && !parsedModel) { @@ -3093,7 +3088,7 @@ export function makeOpenCode2Adapter( const text = input.input?.trim(); // v2 ingests files natively as URI attachments (the runtime's part // builder already applies the v1 native-file gating rules). - const fileParts = toOpenCode2FileParts({ + const fileParts = OpenCode2Runtime.toOpenCode2FileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ @@ -3589,7 +3584,7 @@ export function makeOpenCode2Adapter( // Fork copies the parent's rules; re-assert them anyway in case the // mode changed since (empty clears them when the thread is full-access). if (context.appliedRulesMode) { - const ruleset = buildOpenCode2SessionRules({ + const ruleset = OpenCode2Runtime.buildOpenCode2SessionRules({ runtimeMode: context.appliedRulesMode, ownMcpServerName: context.mcpServerName, }); From fa5fc2cb3e1bebae091052bbc78e539aecd7e933 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 18 Sep 2026 19:11:22 +0300 Subject: [PATCH 15/17] fix(server): isolate opencode2 instance tools --- .../src/provider/Drivers/OpenCode2Driver.ts | 2 +- .../src/provider/Layers/OpenCode2Adapter.ts | 1 + .../src/provider/opencode2Runtime.test.ts | 6 ++++++ apps/server/src/provider/opencode2Runtime.ts | 19 +++++++++++++------ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts index 43ad43766e14..a920fb049445 100644 --- a/apps/server/src/provider/Drivers/OpenCode2Driver.ts +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -121,7 +121,7 @@ export const OpenCode2Driver: ProviderDriver Date: Fri, 18 Sep 2026 19:21:41 +0300 Subject: [PATCH 16/17] fix(server): recover stale opencode2 services --- apps/server/src/provider/opencode2Runtime.ts | 60 ++++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index cee695f51ee2..909b99a01b70 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -265,31 +265,8 @@ const makeOpenCode2Runtime = Effect.gen(function* () { return { client, url, external: true, version } satisfies OpenCode2Connection; }); - const connectBackgroundService = (binaryPath: string | undefined) => + const connectEnsuredBackgroundService = (binaryPath: string | undefined) => Effect.gen(function* () { - // A registered v1 service is skipped here so `ensure` below replaces it - // with a v2 one instead of this adapter adopting it. - const discovered = yield* OpenCodeLocalService.discover({ - version: isOpenCode2Version, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError((cause) => - ensureRuntimeError("connect", "Failed to read the local service registration.", cause), - ), - ); - if (discovered) { - const headers = discovered.auth - ? { authorization: basicAuthHeader(discovered.auth.username, discovered.auth.password) } - : {}; - const client = yield* clientFor(discovered.url, headers); - const version = yield* probeConnection(client); - return { - client, - url: discovered.url, - external: false, - version, - } satisfies OpenCode2Connection; - } const command = openCode2ServiceCommand(binaryPath); const endpoint = yield* OpenCodeLocalService.ensure({ version: isOpenCode2Version, @@ -315,6 +292,41 @@ const makeOpenCode2Runtime = Effect.gen(function* () { } satisfies OpenCode2Connection; }); + const connectBackgroundService = (binaryPath: string | undefined) => + Effect.gen(function* () { + // A registered v1 service is skipped here so `ensure` below replaces it + // with a v2 one instead of this adapter adopting it. + const discovered = yield* OpenCodeLocalService.discover({ + version: isOpenCode2Version, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => + ensureRuntimeError("connect", "Failed to read the local service registration.", cause), + ), + ); + if (discovered) { + const headers = discovered.auth + ? { authorization: basicAuthHeader(discovered.auth.username, discovered.auth.password) } + : {}; + const client = yield* clientFor(discovered.url, headers); + return yield* Effect.gen(function* () { + const version = yield* probeConnection(client); + return { + client, + url: discovered.url, + external: false, + version, + } satisfies OpenCode2Connection; + }).pipe( + Effect.matchEffect({ + onFailure: () => connectEnsuredBackgroundService(binaryPath), + onSuccess: Effect.succeed, + }), + ); + } + return yield* connectEnsuredBackgroundService(binaryPath); + }); + const connect: OpenCode2Runtime["Service"]["connect"] = (input) => Effect.gen(function* () { const serverUrl = input.serverUrl?.trim(); From 3c80cbc9853c7c5ae647083ecaffa516beaa2176 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 18 Sep 2026 19:24:28 +0300 Subject: [PATCH 17/17] fix(server): recover stale opencode2 services --- .../src/provider/opencode2Runtime.test.ts | 54 ++++++++++++++- apps/server/src/provider/opencode2Runtime.ts | 65 +++++++++---------- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/opencode2Runtime.test.ts b/apps/server/src/provider/opencode2Runtime.test.ts index 074f12bb8120..10954fdbb64f 100644 --- a/apps/server/src/provider/opencode2Runtime.test.ts +++ b/apps/server/src/provider/opencode2Runtime.test.ts @@ -1,7 +1,8 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { it, vi } from "@effect/vitest"; +import { Service as OpenCodeLocalService } from "@opencode/client/effect/service"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -64,6 +65,57 @@ const connectWithRequestLog = (serverUrl: string, healthBody?: Record + Effect.gen(function* () { + const discover = vi + .spyOn(OpenCodeLocalService, "discover") + .mockReturnValue(Effect.succeed({ url: "http://127.0.0.1:49374", auth: undefined })); + const ensure = vi + .spyOn(OpenCodeLocalService, "ensure") + .mockReturnValue(Effect.succeed({ url: "http://127.0.0.1:49375" })); + const requestedUrls: string[] = []; + const httpClient = HttpClient.make((request) => { + requestedUrls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.startsWith("http://127.0.0.1:49374/") + ? new Response("Unavailable", { status: 503 }) + : Response.json(HEALTH_BODY), + ), + ); + }); + yield* Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + const connection = yield* runtime.connect({ binaryPath: "/opt/opencode2" }); + NodeAssert.equal(connection.url, "http://127.0.0.1:49375"); + NodeAssert.equal(connection.version, "2.0.8"); + NodeAssert.deepEqual(requestedUrls, [ + "http://127.0.0.1:49374/api/info", + "http://127.0.0.1:49375/api/info", + ]); + NodeAssert.deepEqual(ensure.mock.calls[0]?.[0]?.command, [ + "/opt/opencode2", + "serve", + "--service", + ]); + }).pipe( + Effect.provide( + OpenCode2RuntimeLive.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient)), + Layer.provide(NodeServices.layer), + ), + ), + Effect.ensuring( + Effect.sync(() => { + discover.mockRestore(); + ensure.mockRestore(); + }), + ), + ); + }), +); + it.effect("probes an external server at its configured URL", () => Effect.gen(function* () { const { exit, requestedUrls } = yield* connectWithRequestLog("http://127.0.0.1:49374"); diff --git a/apps/server/src/provider/opencode2Runtime.ts b/apps/server/src/provider/opencode2Runtime.ts index 909b99a01b70..d2b11e74277d 100644 --- a/apps/server/src/provider/opencode2Runtime.ts +++ b/apps/server/src/provider/opencode2Runtime.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as P from "effect/Predicate"; +import * as Result from "effect/Result"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as NodeURL from "node:url"; @@ -265,8 +266,35 @@ const makeOpenCode2Runtime = Effect.gen(function* () { return { client, url, external: true, version } satisfies OpenCode2Connection; }); - const connectEnsuredBackgroundService = (binaryPath: string | undefined) => + const connectBackgroundService = (binaryPath: string | undefined) => Effect.gen(function* () { + // A registered v1 service is skipped here so `ensure` below replaces it + // with a v2 one instead of this adapter adopting it. + const discovered = yield* OpenCodeLocalService.discover({ + version: isOpenCode2Version, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError((cause) => + ensureRuntimeError("connect", "Failed to read the local service registration.", cause), + ), + ); + if (discovered) { + const headers = discovered.auth + ? { authorization: basicAuthHeader(discovered.auth.username, discovered.auth.password) } + : {}; + const client = yield* clientFor(discovered.url, headers); + const probe = yield* Effect.result(probeConnection(client)); + if (Result.isSuccess(probe)) { + return { + client, + url: discovered.url, + external: false, + version: probe.success, + } satisfies OpenCode2Connection; + } + // The service may have stopped after discovery's own health check. + // Let ensure recover it, then validate the replacement below. + } const command = openCode2ServiceCommand(binaryPath); const endpoint = yield* OpenCodeLocalService.ensure({ version: isOpenCode2Version, @@ -292,41 +320,6 @@ const makeOpenCode2Runtime = Effect.gen(function* () { } satisfies OpenCode2Connection; }); - const connectBackgroundService = (binaryPath: string | undefined) => - Effect.gen(function* () { - // A registered v1 service is skipped here so `ensure` below replaces it - // with a v2 one instead of this adapter adopting it. - const discovered = yield* OpenCodeLocalService.discover({ - version: isOpenCode2Version, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError((cause) => - ensureRuntimeError("connect", "Failed to read the local service registration.", cause), - ), - ); - if (discovered) { - const headers = discovered.auth - ? { authorization: basicAuthHeader(discovered.auth.username, discovered.auth.password) } - : {}; - const client = yield* clientFor(discovered.url, headers); - return yield* Effect.gen(function* () { - const version = yield* probeConnection(client); - return { - client, - url: discovered.url, - external: false, - version, - } satisfies OpenCode2Connection; - }).pipe( - Effect.matchEffect({ - onFailure: () => connectEnsuredBackgroundService(binaryPath), - onSuccess: Effect.succeed, - }), - ); - } - return yield* connectEnsuredBackgroundService(binaryPath); - }); - const connect: OpenCode2Runtime["Service"]["connect"] = (input) => Effect.gen(function* () { const serverUrl = input.serverUrl?.trim();