From 115982c48ce914ed66a381f728cc022fdb6b5f7b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 5 Apr 2026 19:19:55 +0530 Subject: [PATCH 1/2] feat: add opencode provider --- apps/server/package.json | 1 + .../src/provider/Layers/OpenCodeAdapter.ts | 1189 +++++++++++++++++ .../src/provider/Layers/OpenCodeProvider.ts | 158 +++ .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 23 + .../src/provider/Layers/ProviderRegistry.ts | 41 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/OpenCodeAdapter.ts | 12 + .../src/provider/Services/OpenCodeProvider.ts | 9 + apps/server/src/provider/opencodeRuntime.ts | 464 +++++++ apps/server/src/server.ts | 3 + apps/server/src/serverSettings.ts | 2 +- apps/web/src/components/ChatView.tsx | 24 +- .../components/KeybindingsToast.browser.tsx | 1 + .../components/chat/ProviderModelPicker.tsx | 6 +- apps/web/src/components/chat/TraitsPicker.tsx | 90 +- .../chat/composerProviderRegistry.tsx | 53 +- .../components/settings/SettingsPanels.tsx | 29 +- apps/web/src/composerDraftStore.ts | 102 +- apps/web/src/modelSelection.ts | 25 +- apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 4 +- bun.lock | 15 + packages/contracts/src/model.ts | 13 + packages/contracts/src/orchestration.ts | 17 +- packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 26 + packages/shared/src/model.ts | 73 + 29 files changed, 2298 insertions(+), 112 deletions(-) create mode 100644 apps/server/src/provider/Layers/OpenCodeAdapter.ts create mode 100644 apps/server/src/provider/Layers/OpenCodeProvider.ts create mode 100644 apps/server/src/provider/Services/OpenCodeAdapter.ts create mode 100644 apps/server/src/provider/Services/OpenCodeProvider.ts create mode 100644 apps/server/src/provider/opencodeRuntime.ts diff --git a/apps/server/package.json b/apps/server/package.json index e59c7c208c..51d8544b20 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,6 +27,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 0000000000..165fec1b3d --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,1189 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + buildOpenCodePermissionRules, + createOpenCodeSdkClient, + openCodeQuestionId, + parseOpenCodeModelSlug, + startOpenCodeServerProcess, + toOpenCodeFileParts, + toOpenCodePermissionReply, + toOpenCodeQuestionAnswers, + type OpenCodeServerProcess, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface OpenCodeSessionContext { + session: ProviderSession; + readonly client: OpencodeClient; + readonly server: OpenCodeServerProcess; + readonly directory: string; + readonly openCodeSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly messageRoleById: Map; + readonly partById: Map; + readonly emittedTextLengthByPartId: Map; + readonly completedAssistantPartIds: Set; + readonly turns: Array; + activeTurnId: TurnId | undefined; + activeAgent: string | undefined; + activeVariant: string | undefined; + stopped: boolean; + readonly eventsAbortController: AbortController; +} + +export interface OpenCodeAdapterLiveOptions {} + +function nowIso(): string { + return new Date().toISOString(); +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" +> { + return { + eventId: EventId.makeUnsafe(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.makeUnsafe(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.makeUnsafe(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + }; +} + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if ( + normalized.includes("task") || + normalized.includes("agent") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function mapPermissionToRequestType( + permission: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (permission) { + case "bash": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +function resolveTurnSnapshot( + context: OpenCodeSessionContext, + turnId: TurnId, +): OpenCodeTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + resolveTurnSnapshot(context, turnId).items.push(item); +} + +function ensureSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): OpenCodeSessionContext { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (session.stopped) { + throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return session; +} + +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { + return request.questions.map((question, index) => ({ + id: openCodeQuestionId(index, question), + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ...(question.multiple ? { multiSelect: true } : {}), + })); +} + +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; +} + +function textFromPart(part: Part): string | undefined { + switch (part.type) { + case "text": + case "reasoning": + return part.text; + default: + return undefined; + } +} + +function isoFromEpochMs(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return new Date(value).toISOString(); +} + +function messageRoleForPart( + context: OpenCodeSessionContext, + part: Pick, +): "assistant" | "user" | undefined { + const known = context.messageRoleById.get(part.messageID); + if (known) { + return known; + } + return part.type === "tool" ? "assistant" : undefined; +} + +function detailFromToolPart(part: Extract): string | undefined { + switch (part.state.status) { + case "completed": + return part.state.output; + case "error": + return part.state.error; + case "running": + return part.state.title; + default: + return undefined; + } +} + +function toolStateCreatedAt(part: Extract): string | undefined { + switch (part.state.status) { + case "running": + return isoFromEpochMs(part.state.time.start); + case "completed": + case "error": + return isoFromEpochMs(part.state.time.end); + default: + return undefined; + } +} + +function sessionErrorMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return "OpenCode session failed."; + } + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = data && "message" in data ? data.message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : "OpenCode session failed."; +} + +function updateProviderSession( + context: OpenCodeSessionContext, + patch: Partial, + options?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt: nowIso(), + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise { + context.stopped = true; + context.eventsAbortController.abort(); + try { + await context.client.session + .abort({ sessionID: context.openCodeSessionId }) + .catch(() => undefined); + } catch {} + context.server.close(); +} + +export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { + return Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const services = yield* Effect.services(); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitPromise = (event: ProviderRuntimeEvent) => + emit(event).pipe(Effect.runPromiseWith(services)); + + const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { + if (context.stopped) { + return; + } + context.stopped = true; + const turnId = context.activeTurnId; + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }); + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }); + }; + + const startEventPump = (context: OpenCodeSessionContext) => { + void (async () => { + try { + const subscription = await context.client.event.subscribe(undefined, { + signal: context.eventsAbortController.signal, + }); + + for await (const event of subscription.stream) { + const payloadSessionId = + "properties" in event + ? (event.properties as { sessionID?: unknown }).sessionID + : undefined; + if ( + typeof payloadSessionId === "string" && + payloadSessionId !== context.openCodeSessionId + ) { + continue; + } + + const turnId = context.activeTurnId; + + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + const messageTurnId = turnId; + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + const text = textFromPart(part); + if (text === undefined) { + continue; + } + const previousLength = context.emittedTextLengthByPartId.get(part.id) ?? 0; + if (text.length > previousLength) { + context.emittedTextLengthByPartId.set(part.id, text.length); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: messageTurnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw: event, + }), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: text.slice(previousLength), + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: messageTurnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(text.length > 0 ? { detail: text } : {}), + }, + }); + } + } + } + break; + } + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + const role = existingPart ? messageRoleForPart(context, existingPart) : undefined; + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousLength = + context.emittedTextLengthByPartId.get(event.properties.partID) ?? 0; + context.emittedTextLengthByPartId.set( + event.properties.partID, + previousLength + delta.length, + ); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + }), + type: "content.delta", + payload: { + streamKind, + delta, + }, + }); + break; + } + + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + const text = textFromPart(part); + if (messageRole === "assistant" && text !== undefined) { + const previousLength = context.emittedTextLengthByPartId.get(part.id) ?? 0; + if (text.length > previousLength) { + context.emittedTextLengthByPartId.set(part.id, text.length); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw: event, + }), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: text.slice(previousLength), + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(text.length > 0 ? { detail: text } : {}), + }, + }); + } + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + await emitPromise(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } + + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } + + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } + + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } + + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } + + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { status: "running", activeTurnId: turnId }); + } + + if (event.properties.status.type === "retry") { + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; + } + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + ); + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + }), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, raw: event }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } + + default: + break; + } + } + } catch (error) { + if (context.eventsAbortController.signal.aborted || context.stopped) { + return; + } + emitUnexpectedExit( + context, + error instanceof Error ? error.message : "OpenCode event stream failed.", + ); + } + })(); + + context.server.process.once("exit", (code, signal) => { + if (context.stopped) { + return; + } + emitUnexpectedExit( + context, + `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, + ); + }); + }; + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to read OpenCode settings.", + cause, + }), + ), + ); + const binaryPath = settings.providers.opencode.binaryPath; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(existing), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to stop existing OpenCode session.", + cause, + }), + }); + sessions.delete(input.threadId); + } + + const started = yield* Effect.tryPromise({ + try: async () => { + const server = await startOpenCodeServerProcess({ binaryPath }); + const client = createOpenCodeSdkClient({ baseUrl: server.url, directory }); + const openCodeSession = await client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }); + if (!openCodeSession.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + return { server, client, openCodeSession: openCodeSession.data }; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + cause, + }), + }); + + const createdAt = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextLengthByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: false, + eventsAbortController: new AbortController(), + }; + sessions.set(input.threadId, context); + startEventPump(context); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "session.started", + payload: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, + }, + }); + + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.makeUnsafe(`opencode-turn-${randomUUID()}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { provider: PROVIDER, model: context.session.model } + : undefined); + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.agent + : undefined; + const variant = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.variant + : undefined; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + + yield* Effect.tryPromise({ + try: async () => { + await context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", + cause, + }), + }); + + return { + threadId: input.threadId, + turnId, + }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", + cause, + }), + }); + if (turnId ?? context.activeTurnId) { + yield* emit({ + ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: + cause instanceof Error + ? cause.message + : "Failed to submit OpenCode permission reply.", + cause, + }), + }); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", + cause, + }), + }); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(context), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", + cause, + }), + }); + sessions.delete(threadId); + yield* emit({ + ...buildEventBase({ threadId }), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", + cause, + }), + }); + + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.makeUnsafe(entry.info.id), + items: [entry.info, ...entry.parts], + })); + + return { + threadId, + turns, + }; + }, + ); + + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: + cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", + cause, + }), + }); + + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const target = + assistantMessages[Math.max(0, assistantMessages.length - Math.max(1, numTurns))] ?? + null; + if (target) { + yield* Effect.tryPromise({ + try: () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + messageID: target.info.id, + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.revert", + detail: + cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", + cause, + }), + }); + } + + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + await Promise.all( + [...sessions.values()].map((context) => stopOpenCodeContext(context)), + ); + sessions.clear(); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "*", + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }), + ); +} + +export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 0000000000..e55276f7e4 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,158 @@ +import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; +import { Cause, Effect, Equal, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, +} from "../providerSnapshot.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { + createOpenCodeSdkClient, + flattenOpenCodeModels, + loadOpenCodeInventory, + runOpenCodeCommand, + startOpenCodeServerProcess, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +function checkOpenCodeProviderStatus(input: { + readonly settings: OpenCodeSettings; + readonly cwd: string; +}): Effect.Effect { + const checkedAt = new Date().toISOString(); + const customModels = input.settings.customModels; + + const fallback = (cause: unknown, version: string | null = null) => { + const installed = !isCommandMissingCause(cause); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings([], PROVIDER, customModels), + probe: { + installed, + version, + status: "error", + auth: { status: "unknown" }, + message: + installed && cause instanceof Error + ? cause.message + : installed + ? "Failed to probe OpenCode CLI." + : "OpenCode CLI not found on PATH.", + }, + }); + }; + + return Effect.gen(function* () { + const versionExit = yield* Effect.exit( + Effect.tryPromise(() => + runOpenCodeCommand({ + binaryPath: input.settings.binaryPath, + args: ["--version"], + }), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + + const version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + if (!input.settings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: providerModelsFromSettings([], PROVIDER, customModels), + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + const inventoryExit = yield* Effect.exit( + Effect.acquireUseRelease( + Effect.tryPromise(() => + startOpenCodeServerProcess({ binaryPath: input.settings.binaryPath }), + ), + (server) => + Effect.tryPromise(async () => { + const client = createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd }); + return await loadOpenCodeInventory(client); + }), + (server) => Effect.sync(() => server.close()), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through OpenCode.` + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); + }); +} + +export function makeOpenCodeProviderLive() { + return Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + + const getProviderSettings = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + + return yield* makeManagedServerProvider({ + getSettings: getProviderSettings.pipe(Effect.orDie), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider: getProviderSettings.pipe( + Effect.flatMap((settings) => + checkOpenCodeProviderStatus({ + settings, + cwd: serverConfig.cwd, + }), + ), + ), + }); + }), + ); +} + +export const OpenCodeProviderLive = makeOpenCodeProviderLive(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..adc11e261a 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter, OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: "opencode", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), ), ), NodeServices.layer, @@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const openCode = yield* registry.getByProvider("opencode"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(openCode, fakeOpenCodeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c..2026923b5b 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61..5ea390a108 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,12 +31,25 @@ import { } from "./CodexProvider"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider"; +import { ServerConfig } from "../../config"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const fakeOpenCodeSnapshot: ServerProvider = { + provider: "opencode", + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: null, + models: [], + message: "OpenCode test stub", +}; function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ @@ -521,6 +534,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest("/repo", { prefix: "provider-registry-test-" }), + ), + Layer.provideMerge( + Layer.succeed(OpenCodeProvider, { + getSnapshot: Effect.succeed(fakeOpenCodeSnapshot), + refresh: Effect.succeed(fakeOpenCodeSnapshot), + streamChanges: Stream.empty, + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c293..470d68b6f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -8,19 +8,26 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { OpenCodeProviderLive } from "./OpenCodeProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; +import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { - concurrency: "unbounded", - }); + openCodeProvider: OpenCodeProviderShape, +): Effect.Effect> => + Effect.all( + [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot], + { + concurrency: "unbounded", + }, + ); export const haveProvidersChanged = ( previousProviders: ReadonlyArray, @@ -32,19 +39,20 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const openCodeProvider = yield* OpenCodeProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(codexProvider, claudeProvider, openCodeProvider), ); const syncProviders = Effect.fn("syncProviders")(function* (options?: { readonly publish?: boolean; }) { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -60,6 +68,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(openCodeProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { @@ -69,10 +80,16 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "opencode": + yield* openCodeProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, openCodeProvider.refresh], + { + concurrency: "unbounded", + }, + ); break; } return yield* syncProviders(); @@ -93,4 +110,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(OpenCodeProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d696..620a58b785 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 0000000000..5c7ae1133f --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "opencode"; +} + +export class OpenCodeAdapter extends ServiceMap.Service()( + "t3/provider/Services/OpenCodeAdapter", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts new file mode 100644 index 0000000000..094c877593 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface OpenCodeProviderShape extends ServerProviderShape {} + +export class OpenCodeProvider extends ServiceMap.Service()( + "t3/provider/Services/OpenCodeProvider", +) {} diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts new file mode 100644 index 0000000000..725157ee4c --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -0,0 +1,464 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer, type AddressInfo } from "node:net"; +import { pathToFileURL } from "node:url"; + +import type { + ChatAttachment, + ModelCapabilities, + ProviderApprovalDecision, + RuntimeMode, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type FilePartInput, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; + +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; + +const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const ANTHROPIC_VARIANTS = ["high", "max"]; +const GOOGLE_VARIANTS = ["low", "high"]; + +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +export interface OpenCodeServerProcess { + readonly url: string; + readonly process: ChildProcess; + close(): void; +} + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + continue; + } + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + return match?.[1] ?? null; + } + return null; +} + +function isPrimaryAgent(agent: Agent): boolean { + return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); +} + +function inferVariantValues(providerID: string): ReadonlyArray { + if (providerID === "anthropic") { + return ANTHROPIC_VARIANTS; + } + if (providerID === "openai" || providerID === "opencode") { + return OPENAI_VARIANTS; + } + if (providerID.startsWith("google")) { + return GOOGLE_VARIANTS; + } + return []; +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function buildVariantOptions( + providerID: string, + model: ProviderListResponse["all"][number]["models"][string], +) { + const variantValues = Object.keys(model.variants ?? {}); + const resolvedValues = + variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; + const defaultVariant = inferDefaultVariant(providerID, resolvedValues); + + return resolvedValues.map((value) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value, + label: titleCaseSlug(value), + }; + if (defaultVariant === value) { + option.isDefault = true; + } + return option; + }); +} + +function buildAgentOptions(agents: ReadonlyArray) { + const primaryAgents = agents.filter(isPrimaryAgent); + const defaultAgent = + primaryAgents.find((agent) => agent.name === "build")?.name ?? + primaryAgents[0]?.name ?? + undefined; + return primaryAgents.map((agent) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value: agent.name, + label: titleCaseSlug(agent.name), + }; + if (defaultAgent === agent.name) { + option.isDefault = true; + } + return option; + }); +} + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantOptions = buildVariantOptions(input.providerID, input.model); + const agentOptions = buildAgentOptions(input.agents); + return { + ...EMPTY_CAPABILITIES, + ...(variantOptions.length > 0 ? { variantOptions } : {}), + ...(agentOptions.length > 0 ? { agentOptions } : {}), + }; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function toOpenCodeModelSlug(providerID: string, modelID: string): string { + return `${providerID}/${modelID}`; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + return [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +export async function findAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, DEFAULT_HOSTNAME, () => resolve()); + }); + const address = server.address() as AddressInfo; + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +export async function startOpenCodeServerProcess(input: { + readonly binaryPath: string; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = input.port ?? (await findAvailablePort()); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const child = spawn(input.binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + let stdout = ""; + let stderr = ""; + let closed = false; + const close = () => { + if (closed) { + return; + } + closed = true; + child.kill(); + }; + + const url = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + child.off("error", onError); + child.off("exit", onExit); + }; + + const onStdout = (chunk: string) => { + stdout += chunk; + const parsed = parseServerUrlFromOutput(stdout); + if (!parsed) { + return; + } + cleanup(); + resolve(parsed); + }; + + const onStderr = (chunk: string) => { + stderr += chunk; + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onExit = (code: number | null) => { + cleanup(); + reject( + new Error( + [ + `OpenCode server exited before startup completed (code: ${code ?? "unknown"}).`, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + ), + ); + }; + + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + child.once("error", onError); + child.once("exit", onExit); + }); + + return { + url, + process: child, + close, + }; +} + +export async function runOpenCodeCommand(input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; +}): Promise { + const child = spawn(input.binaryPath, [...input.args], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + env: process.env, + }); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + + child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); + + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode) => resolve(exitCode ?? 0)); + }); + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + code, + }; +} + +export function createOpenCodeSdkClient(input: { + readonly baseUrl: string; + readonly directory: string; +}): OpencodeClient { + return createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + throwOnError: true, + }); +} + +export async function loadOpenCodeInventory(client: OpencodeClient): Promise { + const [providerListResult, agentsResult] = await Promise.all([ + client.provider.list(), + client.app.agents(), + ]); + if (!providerListResult.data) { + throw new Error("OpenCode provider inventory was empty."); + } + return { + providerList: providerListResult.data, + agents: agentsResult.data ?? [], + }; +} + +export function flattenOpenCodeModels( + input: OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + models.push({ + slug: toOpenCodeModelSlug(provider.id, model.id), + name: `${provider.name} · ${model.name}`, + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..aef6f66897 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,6 +19,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; @@ -148,9 +149,11 @@ const ProviderLayerLive = Layer.unwrap( const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const openCodeAdapterLayer = makeOpenCodeAdapterLive(); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(openCodeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 79fdc29a8b..5d36c99ff0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -91,7 +91,7 @@ export class ServerSettingsService extends ServiceMap.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..0cdd5e79f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -20,7 +20,11 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + createModelSelection, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -1022,11 +1026,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const selectedModelSelection = useMemo( - () => ({ - provider: selectedProvider, - model: selectedModel, - ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), - }), + () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedModelForPicker = selectedModel; @@ -1407,6 +1407,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + opencode: providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], }), [providerStatuses], ); @@ -3015,14 +3016,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || + const threadCreateModelSelection = createModelSelection( + selectedProvider, + selectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; + selectedModelSelection.options, + ); // Auto-title from first message if (isFirstMessage && isServerThread) { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 187ecf497a..a829d899e6 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -71,6 +71,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + opencode: { enabled: true, binaryPath: "", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 01fa37516e..8b20237a83 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, + opencode: OpenCodeIcon, cursor: CursorIcon, }; export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [ - { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, -] as const; +const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; function providerIconClassName( provider: ProviderKind | ProviderPickerKind, diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad53..58ff751449 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,6 +1,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, + type OpenCodeModelOptions, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, @@ -52,9 +53,26 @@ function getRawEffort( if (provider === "codex") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } + if (provider === "opencode") { + return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); + } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } +function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { + return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); +} + +function resolveNamedOption( + options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }>, + raw: string | null, +): string | null { + if (raw && options.some((option) => option.value === raw)) { + return raw; + } + return options.find((option) => option.isDefault)?.value ?? null; +} + function getRawContextWindow( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, @@ -73,6 +91,12 @@ function buildNextOptions( if (provider === "codex") { return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; } + if (provider === "opencode") { + return { + ...(modelOptions as OpenCodeModelOptions | undefined), + ...patch, + } as OpenCodeModelOptions; + } return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; } @@ -85,15 +109,21 @@ function getSelectedTraits( allowPromptInjectedEffort: boolean, ) { const caps = getProviderModelCapabilities(models, model, provider); - const effortLevels = allowPromptInjectedEffort - ? caps.reasoningEffortLevels - : caps.reasoningEffortLevels.filter( - (option) => !caps.promptInjectedEffortLevels.includes(option.value), - ); + const effortLevels = + provider === "opencode" + ? (caps.variantOptions ?? []) + : allowPromptInjectedEffort + ? caps.reasoningEffortLevels + : caps.reasoningEffortLevels.filter( + (option) => !caps.promptInjectedEffortLevels.includes(option.value), + ); // Resolve effort from options (provider-specific key) const rawEffort = getRawEffort(provider, modelOptions); - const effort = resolveEffort(caps, rawEffort) ?? null; + const effort = + provider === "opencode" + ? resolveNamedOption(effortLevels, rawEffort) + : (resolveEffort(caps, rawEffort) ?? null); // Thinking toggle (only for models that support it) const thinkingEnabled = caps.supportsThinkingToggle @@ -124,6 +154,10 @@ function getSelectedTraits( const ultrathinkInBodyText = ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); + const agentOptions = caps.agentOptions ?? []; + const selectedAgent = + provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null; + return { caps, effort, @@ -135,6 +169,8 @@ function getSelectedTraits( defaultContextWindow, ultrathinkPromptControlled, ultrathinkInBodyText, + agentOptions, + selectedAgent, }; } @@ -182,6 +218,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ defaultContextWindow, ultrathinkPromptControlled, ultrathinkInBodyText, + agentOptions, + selectedAgent, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); @@ -190,6 +228,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ if (!value) return; const nextOption = effortLevels.find((option) => option.value === value); if (!nextOption) return; + if (provider === "opencode") { + updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); + return; + } if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { const nextPrompt = prompt.trim().length === 0 @@ -221,7 +263,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { + if ( + effort === null && + thinkingEnabled === null && + contextWindowOptions.length <= 1 && + agentOptions.length === 0 + ) { return null; } @@ -230,7 +277,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ {effort ? ( <> -
Effort
+
+ {provider === "opencode" ? "Variant" : "Effort"} +
{ultrathinkInBodyText ? (
Your prompt contains "ultrathink" in the text. Remove it to change effort. @@ -247,7 +296,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ disabled={ultrathinkInBodyText} > {option.label} - {option.value === defaultEffort ? " (default)" : ""} + {provider !== "opencode" && option.value === defaultEffort ? " (default)" : ""} ))} @@ -315,6 +364,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ) : null} + {agentOptions.length > 0 ? ( + <> + + +
Agent
+ { + updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value })); + }} + > + {agentOptions.map((option) => ( + + {option.label} + {option.isDefault ? " (default)" : ""} + + ))} + +
+ + ) : null} ); }); @@ -342,6 +412,7 @@ export const TraitsPicker = memo(function TraitsPicker({ contextWindow, defaultContextWindow, ultrathinkPromptControlled, + selectedAgent, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort @@ -361,6 +432,7 @@ export const TraitsPicker = memo(function TraitsPicker({ : `Thinking ${thinkingEnabled ? "On" : "Off"}`, ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), ...(contextWindowLabel ? [contextWindowLabel] : []), + ...(selectedAgent ? [selectedAgent] : []), ] .filter(Boolean) .join(" · "); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..aa1aa6fb63 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -8,10 +8,7 @@ import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { getProviderModelCapabilities } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -import { - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, -} from "@t3tools/shared/model"; +import { normalizeProviderModelOptionsWithCapabilities } from "@t3tools/shared/model"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -63,16 +60,20 @@ function getProviderStateFromCapabilities( ? providerOptions.effort : "reasoningEffort" in providerOptions ? providerOptions.reasoningEffort - : null + : "variant" in providerOptions + ? providerOptions.variant + : null : null; - const promptEffort = resolveEffort(caps, rawEffort) ?? null; + const promptEffort = + provider === "opencode" ? (rawEffort ?? null) : (resolveEffort(caps, rawEffort) ?? null); // Normalize options for dispatch - const normalizedOptions = - provider === "codex" - ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( + provider, + caps, + providerOptions, + ); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -155,6 +156,38 @@ const composerProviderRegistry: Record = { /> ), }, + opencode: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..f769db9e91 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -20,6 +20,7 @@ import { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; import { @@ -112,6 +113,12 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, + { + provider: "opencode", + title: "OpenCode", + binaryPlaceholder: "OpenCode binary path", + binaryDescription: "Path to the OpenCode binary", + }, ] as const; const PROVIDER_STATUS_STYLES = { @@ -537,12 +544,18 @@ export function GeneralSettingsPanel() { DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0, ), + opencode: Boolean( + settings.providers.opencode.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath || + settings.providers.opencode.customModels.length > 0, + ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", + opencode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -1029,7 +1042,7 @@ export function GeneralSettingsPanel() { textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: { provider, model }, + textGenerationModelSelection: createModelSelection(provider, model), }, serverProviders, ), @@ -1054,11 +1067,11 @@ export function GeneralSettingsPanel() { textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), - }, + textGenerationModelSelection: createModelSelection( + textGenProvider, + textGenModel, + nextOptions, + ), }, serverProviders, ), @@ -1385,7 +1398,9 @@ export function GeneralSettingsPanel() { placeholder={ providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" - : "claude-sonnet-5-0" + : providerCard.provider === "opencode" + ? "openai/gpt-5" + : "claude-sonnet-5-0" } spellCheck={false} /> diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..47f887a959 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,7 +15,7 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -407,7 +407,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" ? value : null; + return value === "codex" || value === "claudeAgent" || value === "opencode" ? value : null; } function normalizeProviderModelOptions( @@ -424,6 +424,10 @@ function normalizeProviderModelOptions( candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record) : null; + const openCodeCandidate = + candidate?.opencode && typeof candidate.opencode === "object" + ? (candidate.opencode as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -492,12 +496,29 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const openCodeVariant = + typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 + ? openCodeCandidate.variant + : undefined; + const openCodeAgent = + typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 + ? openCodeCandidate.agent + : undefined; + const opencode = + openCodeVariant !== undefined || openCodeAgent !== undefined + ? { + ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), + ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), + } + : undefined; + + if (!codex && !claude && !opencode) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(opencode ? { opencode } : {}), }; } @@ -528,12 +549,8 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; - return { - provider, - model, - ...(options ? { options } : {}), - }; + const options = modelOptions?.[provider]; + return createModelSelection(provider, model, options); } // ── Legacy sync helpers (used only during migration from v2 storage) ── @@ -546,11 +563,7 @@ function legacySyncModelSelectionOptions( return null; } const options = modelOptions?.[modelSelection.provider]; - return { - provider: modelSelection.provider, - model: modelSelection.model, - ...(options ? { options } : {}), - }; + return createModelSelection(modelSelection.provider, modelSelection.model, options); } function legacyMergeModelSelectionIntoProviderModelOptions( @@ -594,17 +607,16 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "opencode"] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = { + result[provider] = createModelSelection( provider, - model: - modelSelection?.provider === provider - ? modelSelection.model - : DEFAULT_MODEL_BY_PROVIDER[provider], + modelSelection?.provider === provider + ? modelSelection.model + : DEFAULT_MODEL_BY_PROVIDER[provider], options, - }; + ); } } } @@ -1636,11 +1648,11 @@ export const useComposerDraftStore = create()( nextMap[normalized.provider] = normalized; } else { // No options in selection → preserve existing options, update provider+model - nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), - }; + nextMap[normalized.provider] = createModelSelection( + normalized.provider, + normalized.model, + current?.options, + ); } } const nextActiveProvider = normalized?.provider ?? base.activeProvider; @@ -1676,17 +1688,17 @@ export const useComposerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ["codex", "claudeAgent", "opencode"] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { - nextMap[provider] = { + nextMap[provider] = createModelSelection( provider, - model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], - options: opts, - }; + current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], + opts, + ); } else if (current?.options) { // Remove options but keep the selection const { options: _, ...rest } = current; @@ -1732,11 +1744,11 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - options: providerOpts, - }; + nextMap[normalizedProvider] = createModelSelection( + normalizedProvider, + currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + providerOpts, + ); } else if (currentForProvider?.options) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; @@ -1750,16 +1762,16 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - } as ModelSelection); + createModelSelection( + normalizedProvider, + DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + ); if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, - provider: normalizedProvider, - options: providerOpts, - }; + nextStickyMap[normalizedProvider] = createModelSelection( + normalizedProvider, + stickyBase.model, + providerOpts, + ); } else if (stickyBase.options) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adf..5ee426e2c9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,7 +4,11 @@ import { type ProviderKind, type ServerProvider, } from "@t3tools/contracts"; -import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; +import { + createModelSelection, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { @@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, + { value: "opencode", label: "OpenCode", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6e768c4ef8..4cfb36a178 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -81,7 +81,7 @@ function updateProject( return changed ? next : projects; } -function normalizeModelSelection( +function normalizeModelSelection( selection: T, ): T { return { @@ -494,7 +494,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { return providerName; } return "codex"; diff --git a/bun.lock b/bun.lock index af243cf4eb..16098f0404 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -507,6 +508,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], @@ -939,6 +942,8 @@ "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -1179,6 +1184,8 @@ "isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1437,6 +1444,8 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1563,6 +1572,10 @@ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -1789,6 +1802,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e05..505558d584 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -22,9 +22,16 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const OpenCodeModelOptions = Schema.Struct({ + variant: Schema.optional(TrimmedNonEmptyString), + agent: Schema.optional(TrimmedNonEmptyString), +}); +export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + opencode: Schema.optional(OpenCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -48,12 +55,15 @@ export const ModelCapabilities = Schema.Struct({ supportsThinkingToggle: Schema.Boolean, contextWindowOptions: Schema.Array(ContextWindowOption), promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), + variantOptions: Schema.optional(Schema.Array(EffortOption)), + agentOptions: Schema.optional(Schema.Array(EffortOption)), }); export type ModelCapabilities = typeof ModelCapabilities.Type; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + opencode: "openai/gpt-5", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -62,6 +72,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", + opencode: "openai/gpt-5", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -86,6 +97,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + opencode: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..62b1325d72 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -23,7 +23,7 @@ export const ORCHESTRATION_WS_METHODS = { replayEvents: "orchestration.replayEvents", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -55,7 +55,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const OpenCodeModelSelection = Schema.Struct({ + provider: Schema.Literal("opencode"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(OpenCodeModelOptions), +}); +export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + OpenCodeModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f6..bc221c9bd2 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "opencode.sdk.event", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6..023b98a83f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -6,6 +6,7 @@ import { ClaudeModelOptions, CodexModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + OpenCodeModelOptions, } from "./model"; import { ModelSelection } from "./orchestration"; @@ -71,6 +72,13 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const OpenCodeSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("opencode"), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type OpenCodeSettings = typeof OpenCodeSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), @@ -93,6 +101,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); @@ -135,6 +144,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), }); +const OpenCodeModelOptionsPatch = Schema.Struct({ + variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), + agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -146,6 +160,11 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("opencode")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(OpenCodeModelOptionsPatch), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -161,6 +180,12 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OpenCodeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -175,6 +200,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2aa378cf63..4f2c5f2a3a 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -6,7 +6,9 @@ import { type CodexModelOptions, type ModelCapabilities, type ModelSelection, + type OpenCodeModelOptions, type ProviderKind, + type ProviderModelOptions, } from "@t3tools/contracts"; export interface SelectableModelOption { @@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +function resolveLabeledOption( + options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, + raw: string | null | undefined, +): string | undefined { + if (!options || options.length === 0) { + return raw ?? undefined; + } + if (raw && options.some((option) => option.value === raw)) { + return raw; + } + return options.find((option) => option.isDefault)?.value; +} + +export function normalizeOpenCodeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: OpenCodeModelOptions | null | undefined, +): OpenCodeModelOptions | undefined { + const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); + const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); + const nextOptions: OpenCodeModelOptions = { + ...(variant ? { variant } : {}), + ...(agent ? { agent } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeProviderModelOptionsWithCapabilities( + provider: ProviderKind, + caps: ModelCapabilities, + modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, +): ProviderModelOptions[ProviderKind] | undefined { + switch (provider) { + case "codex": + return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); + case "claudeAgent": + return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); + case "opencode": + return normalizeOpenCodeModelOptionsWithCapabilities( + caps, + modelOptions as OpenCodeModelOptions, + ); + } +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -196,6 +242,33 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +export function createModelSelection( + provider: ProviderKind, + model: string, + options?: ProviderModelOptions[ProviderKind] | undefined, +): ModelSelection { + switch (provider) { + case "codex": + return { + provider, + model, + ...(options ? { options: options as CodexModelOptions } : {}), + }; + case "claudeAgent": + return { + provider, + model, + ...(options ? { options: options as ClaudeModelOptions } : {}), + }; + case "opencode": + return { + provider, + model, + ...(options ? { options: options as OpenCodeModelOptions } : {}), + }; + } +} + /** * Resolve the actual API model identifier from a model selection. * From 2811ba6ed85902c5ca1ac1c116ebbe04fc98a650 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 5 Apr 2026 19:20:29 +0530 Subject: [PATCH 2/2] feat(git): add opencode text generation --- .../src/git/Layers/OpenCodeTextGeneration.ts | 263 ++++++++++++++++++ .../src/git/Layers/RoutingTextGeneration.ts | 22 +- .../server/src/git/Services/TextGeneration.ts | 2 +- 3 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/git/Layers/OpenCodeTextGeneration.ts diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts new file mode 100644 index 0000000000..036e030098 --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -0,0 +1,263 @@ +import { Effect, Layer, Schema } from "effect"; + +import { + TextGenerationError, + type ChatAttachment, + type OpenCodeModelSelection, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { ServerConfig } from "../../config.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "../Utils.ts"; +import { + createOpenCodeSdkClient, + parseOpenCodeModelSlug, + startOpenCodeServerProcess, + toOpenCodeFileParts, +} from "../../provider/opencodeRuntime.ts"; + +const makeOpenCodeTextGeneration = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* ServerSettingsService; + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: OpenCodeModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const settings = yield* serverSettingsService.getSettings.pipe( + Effect.map((value) => value.providers.opencode), + Effect.orElseSucceed(() => ({ enabled: true, binaryPath: "opencode", customModels: [] })), + ); + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const structuredOutput = yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => startOpenCodeServerProcess({ binaryPath: settings.binaryPath }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", + cause, + }), + }), + (server) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(input.modelSelection.options?.agent + ? { agent: input.modelSelection.options.agent } + : {}), + ...(input.modelSelection.options?.variant + ? { variant: input.modelSelection.options.variant } + : {}), + format: { + type: "json_schema", + schema: toJsonSchemaObject(input.outputSchemaJson) as Record, + }, + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const structured = result.data?.info.structured; + if (structured === undefined) { + throw new Error("OpenCode returned no structured output."); + } + return structured; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: + cause instanceof Error ? cause.message : "OpenCode text generation request failed.", + cause, + }), + }), + (server) => Effect.sync(() => server.close()), + ); + + return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: 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: TextGenerationShape["generatePrContent"] = Effect.fn( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index dee12a3e0e..00db2a40a6 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,10 @@ class ClaudeTextGen extends ServiceMap.Service - provider === "claudeAgent" ? claude : codex; + provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; return { generateCommitMessage: (input) => @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalOpenCodeLayer = Layer.effect( + OpenCodeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(OpenCodeTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalOpenCodeLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index f4354c7a99..d09b6dce02 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode"; export interface CommitMessageGenerationInput { cwd: string;