diff --git a/README.md b/README.md index c2349e72860a..eba25e0bb737 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Oh My Pi. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode and Oh My Pi. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - OMP: install [OMP](https://omp.sh), run `omp`, and use `/login` to configure a model provider ### Try it out (install-free) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..3c2b650b1738 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,6 +1,5 @@ import { useColorScheme } from "react-native"; -import { Path, Svg } from "react-native-svg"; - +import { Circle, Path, Rect, Svg } from "react-native-svg"; type ProviderIconProps = { readonly provider: string | null | undefined; readonly size?: number; @@ -58,6 +57,21 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "omp") { + return ( + + + + + + + + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..5da6e704b790 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,6 +18,9 @@ const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; +const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; +const emitUnsupportedElicitation = process.env.T3_ACP_EMIT_UNSUPPORTED_ELICITATION === "1"; +const extraModelId = process.env.T3_ACP_EXTRA_MODEL_ID?.trim(); const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; @@ -219,6 +222,7 @@ function configOptions(): ReadonlyArray { { value: "composer-2", name: "Composer 2" }, { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + ...(extraModelId ? [{ value: extraModelId, name: extraModelId }] : []), ], }, ]; @@ -772,6 +776,25 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitElicitation) { + yield* agent.client.elicit({ + mode: "form", + sessionId: requestedSessionId, + message: "Choose an OMP strategy", + requestedSchema: { + type: "object", + required: ["strategy"], + properties: { + strategy: { + type: "string", + title: "Strategy", + enum: ["safe", "fast"], + }, + }, + }, + }); + return { stopReason: "end_turn" }; + } if (emitXAiAskUserQuestion) { const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { @@ -810,6 +833,25 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitUnsupportedElicitation) { + yield* agent.client.elicit({ + mode: "form", + sessionId: requestedSessionId, + message: "Describe the requested change", + requestedSchema: { + type: "object", + required: ["description"], + properties: { + description: { + type: "string", + title: "Description", + }, + }, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitForeignSessionUpdates) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts new file mode 100644 index 000000000000..c54a81135e19 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -0,0 +1,183 @@ +/** + * OmpDriver — provider driver for the Oh My Pi (`omp`) ACP runtime. + */ +import { OmpSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { ServerConfig } from "../../config.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOmpTextGeneration } from "../../textGeneration/OmpTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOmpAdapter } from "../Layers/OmpAdapter.ts"; +import { checkOmpProviderStatus, makePendingOmpProvider } from "../Layers/OmpProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeOmpSettings = Schema.decodeSync(OmpSettings); +const DRIVER_KIND = ProviderDriverKind.make("omp"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +function isOmpNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/omp") || + normalized.endsWith("/.local/bin/omp.exe") || + normalized.endsWith("/.omp/bin/omp") || + normalized.endsWith("/.omp/bin/omp.exe") + ); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@oh-my-pi/pi-coding-agent", + homebrewFormula: "can1357/tap/omp", + nativeUpdate: { + executable: "omp", + args: ["update"], + lockKey: "omp-native", + isCommandPath: isOmpNativeCommandPath, + }, +}); + +export type OmpDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OmpDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OMP", + supportsMultipleInstances: true, + }, + configSchema: OmpSettings, + defaultConfig: (): OmpSettings => decodeOmpSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OmpSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeOmpAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOmpProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOmpProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap(publishSnapshot), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OMP snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AcpAdapter.ts b/apps/server/src/provider/Layers/AcpAdapter.ts new file mode 100644 index 000000000000..dcdaff0025b1 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAdapter.ts @@ -0,0 +1,1228 @@ +/** + * Provider-neutral ACP adapter lifecycle and factory. + * + * @module AcpAdapter + */ + +import { + ApprovalRequestId, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { buildAcpElicitationForm } from "../acp/AcpElicitation.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +type AcpSessionRuntimeOptions = AcpSessionRuntime.AcpSessionRuntimeOptions; +type AcpSessionRuntimeShape = AcpSessionRuntime.AcpSessionRuntime["Service"]; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const ACP_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface AcpProviderAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`cursor`). + */ + readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the settings captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface AcpProviderRuntimeFactoryInput { + readonly settings: Settings; + readonly environment?: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cwd: string; + readonly threadId: ThreadId; + readonly runtimeMode: RuntimeMode; + readonly resumeSessionId?: string; + readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; + readonly nativeLoggers: Pick; +} + +interface AcpProviderModelSelectionInput { + readonly runtime: AcpSessionRuntimeShape; + readonly threadId: ThreadId; + readonly model: string; + readonly selections: ReadonlyArray | null | undefined; +} + +export interface AcpProviderAdapterDefinition { + readonly provider: typeof ProviderDriverKind.Type; + readonly defaultInstanceId: typeof ProviderInstanceId.Type; + readonly displayName: string; + readonly settings: Settings; + readonly options?: AcpProviderAdapterLiveOptions; + readonly registerExtensions?: ( + input: AcpProviderExtensionRegistrationInput, + ) => Effect.Effect; + readonly userInputRequestMethod?: string; + readonly supportsRollback?: boolean; + readonly shouldAutoApprovePermission?: (input: { + readonly runtimeMode: RuntimeMode; + readonly permissionKind: string | "unknown"; + }) => boolean; + readonly makeRuntime: ( + input: AcpProviderRuntimeFactoryInput, + ) => Effect.Effect; + readonly applyModelSelection: ( + input: AcpProviderModelSelectionInput, + ) => Effect.Effect; + readonly resolveModelId: (model: string | null | undefined) => string; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +export interface AcpSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + promptsInFlight: number; + stopped: boolean; +} + +export interface AcpProviderExtensionRegistrationInput { + readonly acp: AcpSessionRuntimeShape; + readonly threadId: ThreadId; + readonly getContext: () => AcpSessionContext | undefined; + readonly pendingUserInputs: Map; + readonly randomUUIDv4: Effect.Effect; + readonly makeEventStamp: () => Effect.Effect< + { eventId: EventId; createdAt: string }, + ProviderAdapterRequestError, + never + >; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly mapExtensionFailure: ( + effect: Effect.Effect, + ) => Effect.Effect; + readonly logNative: ( + threadId: ThreadId, + method: string, + payload: unknown, + source: "acp.jsonrpc" | `acp.${string}.extension`, + ) => Effect.Effect; + readonly emitPlanUpdate: ( + context: AcpSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | `acp.${string}.extension`, + method: string, + ) => Effect.Effect; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseAcpResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== ACP_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).split(" ").includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntimeShape; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly applyModelSelection: (input: { + readonly runtime: AcpSessionRuntimeShape; + readonly model: string; + readonly selections: ReadonlyArray | null | undefined; + }) => Effect.Effect; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* input.applyModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + }); + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const desiredKinds = ["allow_always", "allow_once"] as const; + for (const kind of desiredKinds) { + const option = request.options.find((candidate) => candidate.kind === kind); + if (typeof option?.optionId === "string" && option.optionId.trim()) { + return option.optionId.trim(); + } + } + + const aliases = new Set(desiredKinds.flatMap((kind) => [kind, kind.replaceAll("_", "-")])); + return request.options.find((option) => aliases.has(option.optionId))?.optionId; +} + +export function makeAcpProviderAdapter( + definition: AcpProviderAdapterDefinition, +) { + return Effect.gen(function* () { + const PROVIDER = definition.provider; + const options = definition.options; + const boundInstanceId = options?.instanceId ?? definition.defaultInstanceId; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: `Failed to generate ${definition.displayName} runtime identifier.`, + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: `Failed to process ${definition.displayName} ACP extension event.`, + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | `acp.${string}.extension`, + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: AcpSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | `acp.${string}.extension`, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: AcpSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const selectedModel = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: AcpSessionContext; + + const resumeSessionId = parseAcpResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the provider settings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { cursor: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveSettings = options?.resolveSettings + ? yield* options.resolveSettings + : definition.settings; + + const acp = yield* definition + .makeRuntime({ + settings: effectiveSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + threadId: input.threadId, + runtimeMode: input.runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + nativeLoggers: acpNativeLoggers, + }) + .pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleElicitation((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/elicitation", params, "acp.jsonrpc"); + const form = buildAcpElicitationForm(params); + if (!form) { + return { action: { action: "cancel" as const } }; + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: form.questions }, + raw: { + source: "acp.jsonrpc", + method: "session/elicitation", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + return form.resolve(resolved); + }), + ), + ); + if (definition.registerExtensions) { + yield* definition.registerExtensions({ + acp, + threadId: input.threadId, + getContext: () => ctx, + pendingUserInputs, + randomUUIDv4, + makeEventStamp, + offerRuntimeEvent, + mapExtensionFailure, + logNative, + emitPlanUpdate, + }); + } + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + const permissionRequest = parsePermissionRequest(params); + const autoApprove = + input.runtimeMode === "full-access" || + definition.shouldAutoApprovePermission?.({ + runtimeMode: input.runtimeMode, + permissionKind: permissionRequest.kind, + }) === true; + if (autoApprove) { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + if (resolved === "cancel") { + return { outcome: { outcome: "cancelled" as const } }; + } + const optionId = acpPermissionOutcome(resolved, params.options); + return optionId === undefined + ? { outcome: { outcome: "cancelled" as const } } + : { outcome: { outcome: "selected" as const, optionId } }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: selectedModel, + applyModelSelection: ({ runtime, model, selections }) => + definition.applyModelSelection({ + runtime, + threadId: input.threadId, + model, + selections, + }), + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: selectedModel?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: ACP_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError(`Failed to process ${definition.displayName} runtime notification.`, { + cause, + }), + ), + Effect.forkIn(sessionScope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: `${definition.displayName} ACP session ready` }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => { + let cleanupContext: AcpSessionContext | undefined; + let promptIncremented = false; + return Effect.gen(function* () { + const { ctx, steeringTurnId, turnId } = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + return { ctx, steeringTurnId, turnId }; + }), + ); + cleanupContext = ctx; + promptIncremented = true; + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = definition.resolveModelId(model); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + applyModelSelection: ({ runtime, model: nextModel, selections }) => + definition.applyModelSelection({ + runtime, + threadId: input.threadId, + model: nextModel, + selections, + }), + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (promptIncremented && cleanupContext) { + cleanupContext.promptsInFlight = Math.max(0, cleanupContext.promptsInFlight - 1); + } + }), + ), + ); + }; + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: definition.userInputRequestMethod ?? "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: ProviderAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + if (definition.supportsRollback) { + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + } + // ACP has no rollback operation. Do not trim local history while the + // provider retains the removed turns; return an explicit error instead. + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/rollback", + detail: `${definition.displayName} ACP sessions do not support rollback.`, + }); + }); + + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError(`Failed to stop ${definition.displayName} sessions.`, { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies ProviderAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a9776..ee0c8fff9dae 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -250,6 +250,31 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("preserves Cursor's local rollback behavior through the shared ACP adapter", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-rollback-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + yield* adapter.sendTurn({ threadId, input: "first", attachments: [] }); + yield* adapter.sendTurn({ threadId, input: "second", attachments: [] }); + + const rolledBack = yield* adapter.rollbackThread(threadId, 1); + assert.equal(rolledBack.turns.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; @@ -310,6 +335,8 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }); const firstTurn = yield* Fiber.join(firstTurnFiber); assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); + const thread = yield* adapter.readThread(threadId); + assert.equal(thread.turns.length, 1); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c2695..ba756c9cc2ca 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,64 +7,19 @@ import { ApprovalRequestId, type CursorSettings, - type ProviderOptionSelection, - EventId, - type ProviderApprovalDecision, - type ProviderInteractionMode, - type ProviderRuntimeEvent, - type ProviderSession, - type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, - type RuntimeMode, - type ThreadId, - TurnId, + type ProviderUserInputAnswers, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PubSub from "effect/PubSub"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/schema"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, -} from "../Errors.ts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "../acp/AcpCoreRuntimeEvents.ts"; -import { - type AcpSessionMode, - type AcpSessionModeState, - parsePermissionRequest, -} from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import type { + AcpProviderAdapterLiveOptions, + AcpProviderExtensionRegistrationInput, +} from "./AcpAdapter.ts"; +import { makeAcpProviderAdapter } from "./AcpAdapter.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { CursorAskQuestionRequest, @@ -74,1109 +29,177 @@ import { extractPlanMarkdown, extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("cursor"); -const CURSOR_RESUME_VERSION = 1 as const; -const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; -const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; -const ACP_APPROVAL_MODE_ALIASES = ["ask"]; - -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; -} - -export interface CursorAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; - /** - * Selections are honored when `modelSelection.instanceId` matches this value. - * Defaults to the legacy built-in instance id (`cursor`). - */ - readonly instanceId?: ProviderInstanceId; - /** - * Optional per-session settings resolver. When provided the adapter yields - * this effect at the start of every session and uses the result instead of - * the `cursorSettings` captured at construction. - * - * Production instances bind settings to the instance scope (the hydration - * layer rebuilds the adapter on config change) and leave this undefined. - * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to - * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads - * the latest snapshot so the closure isn't stale. - */ - readonly resolveSettings?: Effect.Effect; -} - -interface PendingApproval { - readonly decision: Deferred.Deferred; - readonly kind: string | "unknown"; -} - -interface PendingUserInput { - readonly answers: Deferred.Deferred; -} - -interface CursorSessionContext { - readonly threadId: ThreadId; - session: ProviderSession; - readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - /** Number of sendTurn prompts currently in flight or being prepared. - * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ - promptsInFlight: number; - stopped: boolean; -} - -function settlePendingApprovalsAsCancelled( - pendingApprovals: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingApprovals.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { - discard: true, - }, - ); -} - -function settlePendingUserInputsAsEmptyAnswers( - pendingUserInputs: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingUserInputs.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), - { - discard: true, - }, - ); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseCursorResume(raw: unknown): { sessionId: string } | undefined { - if (!isRecord(raw)) return undefined; - if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; - if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; - return { sessionId: raw.sessionId.trim() }; -} - -function normalizeModeSearchText(mode: AcpSessionMode): string { - return [mode.id, mode.name, mode.description] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .join(" ") - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -function findModeByAliases( - modes: ReadonlyArray, - aliases: ReadonlyArray, -): AcpSessionMode | undefined { - const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); - for (const alias of normalizedAliases) { - const exact = modes.find((mode) => { - const id = mode.id.toLowerCase(); - const name = mode.name.toLowerCase(); - return id === alias || name === alias; - }); - if (exact) { - return exact; - } - } - for (const alias of normalizedAliases) { - const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); - if (partial) { - return partial; - } - } - return undefined; -} - -function isPlanMode(mode: AcpSessionMode): boolean { - return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; -} -function resolveRequestedModeId(input: { - readonly interactionMode: ProviderInteractionMode | undefined; - readonly runtimeMode: RuntimeMode; - readonly modeState: AcpSessionModeState | undefined; -}): string | undefined { - const modeState = input.modeState; - if (!modeState) { - return undefined; - } - - if (input.interactionMode === "plan") { - return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; - } - - if (input.runtimeMode === "approval-required") { - return ( - findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? - findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? - modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? - modeState.currentModeId - ); - } +export type CursorAdapterLiveOptions = AcpProviderAdapterLiveOptions; - return ( - findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? - findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? - modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? - modeState.currentModeId - ); -} - -function applyRequestedSessionConfiguration(input: { - readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode | undefined; - readonly modelSelection: - | { - readonly model: string; - readonly options?: ReadonlyArray | null | undefined; - } - | undefined; - readonly mapError: (context: { - readonly cause: import("effect-acp/errors").AcpError; - readonly method: "session/set_config_option" | "session/set_mode"; - }) => E; -}): Effect.Effect { - return Effect.gen(function* () { - if (input.modelSelection) { - yield* applyCursorAcpModelSelection({ - runtime: input.runtime, - model: input.modelSelection.model, - selections: input.modelSelection.options, - mapError: ({ cause }) => - input.mapError({ - cause, - method: "session/set_config_option", - }), - }); - } - - const requestedModeId = resolveRequestedModeId({ - interactionMode: input.interactionMode, - runtimeMode: input.runtimeMode, - modeState: yield* input.runtime.getModeState, - }); - if (!requestedModeId) { - return; - } - - yield* input.runtime.setMode(requestedModeId).pipe( - Effect.mapError((cause) => - input.mapError({ - cause, - method: "session/set_mode", - }), - ), - ); - }); -} - -function selectAutoApprovedPermissionOption( - request: EffectAcpSchema.RequestPermissionRequest, -): string | undefined { - const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); - if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { - return allowAlwaysOption.optionId.trim(); - } - - const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); - if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { - return allowOnceOption.optionId.trim(); - } - - return undefined; -} - -export function makeCursorAdapter( - cursorSettings: CursorSettings, - options?: CursorAdapterLiveOptions, -) { - return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); - const crypto = yield* Crypto.Crypto; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); - - const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const runtimeEventPubSub = yield* PubSub.unbounded(); - - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "crypto/randomUUIDv4", - detail: "Failed to generate Cursor runtime identifier.", - cause, - }), - ), - ); - const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - const mapExtensionFailure = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to process Cursor ACP extension event.", - cause, - }), - ), - ); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const logNative = ( - threadId: ThreadId, - method: string, - payload: unknown, - _source: "acp.jsonrpc" | "acp.cursor.extension", - ) => - Effect.gen(function* () { - if (!nativeEventLogger) return; - const observedAt = yield* nowIso; - yield* nativeEventLogger.write( - { - observedAt, - event: { - id: yield* randomUUIDv4, - kind: "notification", - provider: PROVIDER, - createdAt: observedAt, - method, - threadId, - payload, - }, - }, - threadId, - ); - }); - - const emitPlanUpdate = ( - ctx: CursorSessionContext, - payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }, - rawPayload: unknown, - source: "acp.jsonrpc" | "acp.cursor.extension", - method: string, - ) => - Effect.gen(function* () { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; - if (ctx.lastPlanFingerprint === fingerprint) { - return; - } - ctx.lastPlanFingerprint = fingerprint; - yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload, - source, - method, - rawPayload, - }), - ); - }); - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), - ); - } - return Effect.succeed(ctx); - }; - - const stopSessionInternal = (ctx: CursorSessionContext) => - Effect.gen(function* () { - if (ctx.stopped) return; - ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); - if (ctx.notificationFiber) { - yield* Fiber.interrupt(ctx.notificationFiber); - } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); - sessions.delete(ctx.threadId); - yield* offerRuntimeEvent({ - type: "session.exited", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - payload: { exitKind: "graceful" }, - }); - }); - - const startSession: CursorAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, +const registerCursorExtensions = (input: AcpProviderExtensionRegistrationInput) => + Effect.gen(function* () { + yield* input.acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + input.mapExtensionFailure( Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } - - const cwd = path.resolve(input.cwd.trim()); - const cursorModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + yield* input.logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", ); - let ctx!: CursorSessionContext; - - const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, + const requestId = ApprovalRequestId.make(yield* input.randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + input.pendingUserInputs.set(requestId, { answers }); + const context = input.getContext(); + yield* input.offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* input.makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - }); - - // Resolve the CursorSettings used to spawn the ACP child. Production - // leaves `options.resolveSettings` undefined so we use the value - // captured at adapter construction — per-instance isolation is - // enforced by the hydration layer rebuilding this adapter whenever - // its config changes. Tests set `resolveSettings` to pull the latest - // snapshot from `ServerSettingsService` so that mid-suite - // `updateSettings({ providers: { cursor: { binaryPath } } })` calls - // actually take effect when the next session spawns. - const effectiveCursorSettings = options?.resolveSettings - ? yield* options.resolveSettings - : cursorSettings; - - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", - payload: params, - }, - }); - const resolved = yield* Deferred.await(answers); - pendingUserInputs.delete(requestId); - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ), - ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", - ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - payload: { planMarkdown: extractPlanMarkdown(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/create_plan", - payload: params, - }, - }); - return { accepted: true } as const; - }), - ), - ); - yield* acp.handleExtNotification( - "cursor/update_todos", - CursorUpdateTodosRequest, - (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/update_todos", - params, - "acp.cursor.extension", - ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ), - ); - yield* acp.handleRequestPermission((params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "session/request_permission", - params, - "acp.jsonrpc", - ); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, - }; - }), - ), - ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - yield* applyRequestedSessionConfiguration({ - runtime: acp, - runtimeMode: input.runtimeMode, - interactionMode: undefined, - modelSelection: cursorModelSelection, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), - }); - - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: cursorModelSelection?.model, - threadId: input.threadId, - resumeCursor: { - schemaVersion: CURSOR_RESUME_VERSION, - sessionId: started.sessionId, + turnId: context?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, }, - createdAt: now, - updatedAt: now, - }; - - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - pendingUserInputs, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - promptsInFlight: 0, - stopped: false, - }; - - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - switch (event._tag) { - case "EventStreamBarrier": - yield* Deferred.succeed(event.acknowledge, undefined); - return; - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* emitPlanUpdate( - ctx, - event.payload, - event.rawPayload, - "acp.jsonrpc", - "session/update", - ); - return; - case "ToolCallUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process Cursor runtime notification.", { cause }), - ), - Effect.forkChild, - ); - - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; - - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), + const resolved = yield* Deferred.await(answers); + input.pendingUserInputs.delete(requestId); + yield* input.offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* input.makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { state: "ready", reason: "Cursor ACP session ready" }, + turnId: context?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), + return { answers: resolved }; + }), + ), + ); + yield* input.acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + input.mapExtensionFailure( + Effect.gen(function* () { + yield* input.logNative( + input.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + const context = input.getContext(); + yield* input.offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* input.makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); - - return session; - }).pipe(Effect.scoped), - ); - - const sendTurn: CursorAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent folds - // the new prompt into the ongoing work, so the active turn id is - // reused instead of opening a new turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - // Count this prompt immediately so a superseded in-flight prompt - // resolving from here on does not settle the turn; the matching - // decrement is the `ensuring` below. - ctx.promptsInFlight += 1; - - return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = resolveCursorAcpBaseModelId(model); - yield* applyRequestedSessionConfiguration({ - runtime: ctx.acp, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - modelSelection: - model === undefined - ? undefined - : { - model, - options: turnModelSelection?.options, - }, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + turnId: context?.activeTurnId, + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, }); - ctx.activeTurnId = turnId; - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: resolvedModel }, - }); - } - - const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); - } - if (input.attachments && input.attachments.length > 0) { - for (const attachment of input.attachments) { - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: cause.message, - cause, - }), - ), + return { accepted: true } as const; + }), + ), + ); + yield* input.acp.handleExtNotification( + "cursor/update_todos", + CursorUpdateTodosRequest, + (params) => + input.mapExtensionFailure( + Effect.gen(function* () { + yield* input.logNative( + input.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + const context = input.getContext(); + if (context) { + yield* input.emitPlanUpdate( + context, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", ); - promptParts.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); } - } - - if (promptParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", - }); - } - - const result = yield* ctx.acp - .prompt({ - prompt: promptParts, - }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), - ), - ); - - const turnRecord = ctx.turns.find((turn) => turn.id === turnId); - if (turnRecord) { - turnRecord.items.push({ prompt: promptParts, result }); - } else { - ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolvedModel, - }; - - // Only the last remaining prompt settles the turn — a steer- - // superseded prompt resolving (usually cancelled) while another is - // in flight or pending must leave the merged turn running. - if (ctx.promptsInFlight === 1) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, - }, - }); - } - - return { - threadId: input.threadId, - turnId, - resumeCursor: ctx.session.resumeCursor, - }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); - }), - ), - ); - }); - - const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); - }); - - const respondToRequest: CursorAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingApprovals.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending approval request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.decision, decision); - }); - - const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( - threadId, - requestId, - answers, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingUserInputs.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "cursor/ask_question", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.answers, answers); - }); - - const readThread: CursorAdapterShape["readThread"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - return { threadId, turns: ctx.turns }; - }); - - const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }); - } - const nextLength = Math.max(0, ctx.turns.length - numTurns); - ctx.turns.splice(nextLength); - return { threadId, turns: ctx.turns }; - }); - - const stopSession: CursorAdapterShape["stopSession"] = (threadId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); - }), - ); - - const listSessions: CursorAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); - - const hasSession: CursorAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const c = sessions.get(threadId); - return c !== undefined && !c.stopped; - }); - - const stopAll: CursorAdapterShape["stopAll"] = () => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); - - yield* Effect.addFinalizer(() => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( - Effect.catch((cause) => - Effect.logError("Failed to emit Cursor session shutdown event.", { cause }), + }), ), - Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), - Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), - ), ); - - const streamEvents = Stream.fromPubSub(runtimeEventPubSub); - - return { - provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - streamEvents, - } satisfies CursorAdapterShape; }); + +export function makeCursorAdapter( + cursorSettings: CursorSettings, + options?: CursorAdapterLiveOptions, +) { + return makeAcpProviderAdapter({ + provider: PROVIDER, + defaultInstanceId: ProviderInstanceId.make("cursor"), + displayName: "Cursor", + settings: cursorSettings, + supportsRollback: true, + ...(options ? { options } : {}), + registerExtensions: registerCursorExtensions, + userInputRequestMethod: "cursor/ask_question", + makeRuntime: ({ + threadId, + settings, + environment, + childProcessSpawner, + cwd, + resumeSessionId, + clientInfo, + nativeLoggers, + }) => { + const mcpSession = McpProviderSession.readMcpProviderSession(threadId); + return makeCursorAcpRuntime({ + cursorSettings: settings, + ...(environment ? { environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...nativeLoggers, + }); + }, + applyModelSelection: ({ runtime, threadId, model, selections }) => + applyCursorAcpModelSelection({ + runtime, + model, + selections, + mapError: ({ cause, step }) => + mapAcpToAdapterError( + PROVIDER, + threadId, + step === "set-model" ? "session/set_model" : "session/set_config_option", + cause, + ), + }), + resolveModelId: resolveCursorAcpBaseModelId, + }).pipe(Effect.map((adapter) => adapter satisfies CursorAdapterShape)); } diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts new file mode 100644 index 000000000000..10d7e97360fe --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -0,0 +1,202 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ApprovalRequestId, + OmpSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import { makeOmpAdapter } from "./OmpAdapter.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const decodeOmpSettings = Schema.decodeSync(OmpSettings); + +async function makeMockOmpWrapper(input?: { + readonly argvLogPath?: string; + readonly environment?: Readonly>; +}) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-omp.sh"); + const exports = Object.entries(input?.environment ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const argvLog = input?.argvLogPath + ? `printf '%s\\t' "$@" >> ${JSON.stringify(input.argvLogPath)}\nprintf '\\n' >> ${JSON.stringify(input.argvLogPath)}` + : ""; + const script = `#!/bin/sh +${exports} +${argvLog} +exec bun ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-omp-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.effect("OMP adapter starts a scoped ACP session with profile and runtime policy", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-argv-")), + ); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(argvLogPath, "", "utf8")); + const binaryPath = yield* Effect.promise(() => makeMockOmpWrapper({ argvLogPath })); + const adapter = yield* makeOmpAdapter( + decodeOmpSettings({ binaryPath, profile: "work", enabled: true }), + ); + const threadId = ThreadId.make("omp-session"); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + providerInstanceId: ProviderInstanceId.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }); + + assert.equal(session.provider, "omp"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.deepStrictEqual( + (yield* Effect.promise(() => NodeFSP.readFile(argvLogPath, "utf8"))) + .trim() + .split("\t") + .filter(Boolean), + ["acp", "--profile", "work", "--approval-mode", "yolo"], + ); + const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + assert.equal(rollbackError._tag, "ProviderAdapterRequestError"); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(testLayer)), + ), +); + +it.effect("OMP adapter bridges standard ACP form elicitation to provider user input", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => + makeMockOmpWrapper({ environment: { T3_ACP_EMIT_ELICITATION: "1" } }), + ); + const adapter = yield* makeOmpAdapter(decodeOmpSettings({ binaryPath, enabled: true })); + const threadId = ThreadId.make("omp-elicitation"); + const requested = yield* Deferred.make<{ + readonly requestId: string; + readonly questions: ReadonlyArray<{ readonly id: string }>; + }>(); + const resolved = yield* Deferred.make>(); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.threadId !== threadId) return Effect.void; + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, { + requestId: String(event.requestId), + questions: event.payload.questions, + }).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event.payload.answers).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + providerInstanceId: ProviderInstanceId.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "ask me", attachments: [] }) + .pipe(Effect.forkChild); + + const request = yield* Deferred.await(requested); + assert.deepStrictEqual( + request.questions.map((question) => question.id), + ["strategy"], + ); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(request.requestId), { + strategy: "safe", + }); + yield* Fiber.join(turnFiber); + assert.deepStrictEqual(yield* Deferred.await(resolved), { strategy: "safe" }); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(testLayer)), + ), +); + +it.effect( + "OMP adapter cancels unsupported optionless elicitation without waiting for web input", + () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => + makeMockOmpWrapper({ + environment: { T3_ACP_EMIT_UNSUPPORTED_ELICITATION: "1" }, + }), + ); + const adapter = yield* makeOmpAdapter(decodeOmpSettings({ binaryPath, enabled: true })); + const threadId = ThreadId.make("omp-unsupported-elicitation"); + let sawUserInputRequest = false; + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.threadId === threadId && event.type === "user-input.requested") { + sawUserInputRequest = true; + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + providerInstanceId: ProviderInstanceId.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }); + + // Completion proves the ACP request was answered immediately instead of + // leaving the turn blocked on a prompt the web client cannot render. + yield* adapter.sendTurn({ threadId, input: "ask for free text", attachments: [] }); + assert.isFalse(sawUserInputRequest); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(testLayer)), + ), +); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts new file mode 100644 index 000000000000..9595b5fb57b1 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -0,0 +1,67 @@ +/** + * OMP CLI (`omp acp`) adapter. + * + * OMP speaks the same standard ACP transport as Cursor, so all session, + * turn, event, attachment, resume, permission, and elicitation behavior lives + * in the shared ACP adapter factory. This module owns only OMP-specific spawn, + * authentication, model configuration, and approval-policy choices. + */ +import { type OmpSettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../acp/OmpAcpSupport.ts"; +import type { OmpAdapterShape } from "../Services/OmpAdapter.ts"; +import { makeAcpProviderAdapter, type AcpProviderAdapterLiveOptions } from "./AcpAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("omp"); + +export type OmpAdapterLiveOptions = AcpProviderAdapterLiveOptions; + +export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiveOptions) { + return makeAcpProviderAdapter({ + provider: PROVIDER, + defaultInstanceId: ProviderInstanceId.make("omp"), + displayName: "OMP", + settings: ompSettings, + supportsRollback: false, + ...(options ? { options } : {}), + makeRuntime: ({ + settings, + environment, + childProcessSpawner, + cwd, + runtimeMode, + resumeSessionId, + clientInfo, + nativeLoggers, + }) => + makeOmpAcpRuntime({ + ompSettings: settings, + ...(environment ? { environment } : {}), + childProcessSpawner, + cwd, + runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo, + ...nativeLoggers, + }), + applyModelSelection: ({ runtime, threadId, model, selections }) => + applyOmpAcpModelSelection({ + runtime, + model, + selections, + mapError: ({ cause, step }) => + mapAcpToAdapterError( + PROVIDER, + threadId, + step === "set-model" ? "session/set_model" : "session/set_config_option", + cause, + ), + }), + shouldAutoApprovePermission: ({ runtimeMode, permissionKind }) => + (runtimeMode === "auto-accept-edits" || runtimeMode === "auto") && + (permissionKind === "edit" || permissionKind === "delete" || permissionKind === "move"), + resolveModelId: (model) => model?.trim() || "default", + }).pipe(Effect.map((adapter) => adapter satisfies OmpAdapterShape)); +} diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts new file mode 100644 index 000000000000..490755c3855d --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildOmpCliArgs, buildOmpDiscoveredModels, parseOmpModelsJson } from "./OmpProvider.ts"; + +describe("OmpProvider", () => { + it("parses OMP's machine-readable model inventory", () => { + const parsed = parseOmpModelsJson( + JSON.stringify({ + models: [ + { + provider: "anthropic", + id: "claude-sonnet-4-6", + selector: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + reasoning: true, + thinking: ["low", "medium", "high"], + input: ["text", "image"], + }, + { + provider: "local", + id: "qwen", + selector: "local/qwen", + name: "Qwen", + reasoning: false, + thinking: null, + input: ["text"], + }, + ], + }), + ); + + expect(parsed).toHaveLength(2); + expect(buildOmpDiscoveredModels(parsed ?? [])).toEqual([ + { + slug: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + subProvider: "anthropic", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + description: "Reasoning effort used by OMP for this model.", + type: "select", + currentValue: "auto", + options: [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto", isDefault: true }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + { + slug: "local/qwen", + name: "Qwen", + subProvider: "local", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ]); + }); + + it("rejects malformed JSON and prepends the selected profile", () => { + expect(parseOmpModelsJson("not json")).toBeUndefined(); + expect(buildOmpCliArgs({ profile: "work" }, ["models", "--json"])).toEqual([ + "--profile", + "work", + "models", + "--json", + ]); + expect(buildOmpCliArgs({ profile: "" }, ["acp"])).toEqual(["acp"]); + }); +}); diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts new file mode 100644 index 000000000000..a0e3ac179542 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -0,0 +1,350 @@ +import { + type ModelCapabilities, + type OmpSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + collectStreamAsString, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + ProviderCommandNotFoundError, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const OMP_PRESENTATION = { + displayName: "OMP", + showInteractionModeToggle: true, +} as const; +const OMP_VERSION_TIMEOUT_MS = 5_000; +const MINIMUM_OMP_ACP_VERSION = "15.13.1"; +const OMP_MODELS_TIMEOUT_MS = 20_000; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +interface OmpModelJson { + readonly provider: string; + readonly id: string; + readonly selector: string; + readonly name: string; + readonly reasoning: boolean; + readonly thinking: ReadonlyArray | null; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function titleCase(value: string): string { + return value + .split(/[-_\s]+/u) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function parseOmpModel(value: unknown): OmpModelJson | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + const provider = nonEmptyString(record.provider); + const id = nonEmptyString(record.id); + const name = nonEmptyString(record.name); + if (!provider || !id || !name) return undefined; + const selector = nonEmptyString(record.selector) ?? `${provider}/${id}`; + const thinking = Array.isArray(record.thinking) + ? record.thinking.flatMap((entry) => (nonEmptyString(entry) ? [nonEmptyString(entry)!] : [])) + : null; + return { + provider, + id, + selector, + name, + reasoning: record.reasoning === true, + thinking, + }; +} + +export function parseOmpModelsJson(raw: string): ReadonlyArray | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const models = (parsed as Record).models; + if (!Array.isArray(models)) return undefined; + return models.flatMap((model) => { + const parsedModel = parseOmpModel(model); + return parsedModel ? [parsedModel] : []; + }); +} + +function ompModelCapabilities(model: OmpModelJson): ModelCapabilities { + if (!model.reasoning) return EMPTY_CAPABILITIES; + const efforts = Array.from(new Set(model.thinking ?? [])); + return createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + description: "Reasoning effort used by OMP for this model.", + type: "select", + currentValue: "auto", + options: [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto", isDefault: true }, + ...efforts + .filter((effort) => effort !== "off" && effort !== "auto") + .map((effort) => ({ id: effort, label: titleCase(effort) })), + ], + }, + ], + }); +} + +export function buildOmpDiscoveredModels( + models: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const discovered: ServerProviderModel[] = []; + for (const model of models) { + if (seen.has(model.selector)) continue; + seen.add(model.selector); + discovered.push({ + slug: model.selector, + name: model.name, + subProvider: model.provider, + isCustom: false, + capabilities: ompModelCapabilities(model), + }); + } + return discovered; +} + +export function buildOmpCliArgs( + settings: Pick, + args: ReadonlyArray, +): ReadonlyArray { + const profile = settings.profile.trim(); + // OMP stops parsing global profile flags after a non-launch subcommand such + // as `models`, so the profile must precede that subcommand. (`omp acp` is a + // launch-shaped exception and is assembled separately by OmpAcpSupport.) + return profile ? ["--profile", profile, ...args] : [...args]; +} + +const runOmpCommand = ( + settings: OmpSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(settings.binaryPath, [...args], { + env: environment, + shell: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0 && /is not recognized/u.test(stderr)) { + return yield* new ProviderCommandNotFoundError({ + binaryPath: settings.binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const makePendingOmpProvider = (settings: OmpSettings): Effect.Effect => + Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking OMP availability..." + : "OMP is disabled in T3 Code settings.", + }, + }); + }); + +function commandFailureMessage(command: string, result: CommandResult): string { + const detail = detailFromResult(result); + return detail + ? `OMP \`${command}\` failed: ${detail}` + : `OMP \`${command}\` exited without usable output.`; +} + +export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(function* ( + settings: OmpSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES); + const buildFailure = (input: { + readonly installed: boolean; + readonly version?: string | null; + readonly message: string; + readonly status?: "warning" | "error"; + }) => + buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: input.installed, + version: input.version ?? null, + status: input.status ?? "error", + auth: { status: "unknown" }, + message: input.message, + }, + }); + + if (!settings.enabled) { + return buildFailure({ + installed: false, + status: "warning", + message: "OMP is disabled in T3 Code settings.", + }); + } + + const versionProbe = yield* runOmpCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(OMP_VERSION_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionProbe)) { + return buildFailure({ + installed: !isCommandMissingCause(versionProbe.failure), + message: isCommandMissingCause(versionProbe.failure) + ? "OMP CLI (`omp`) is not installed or not on PATH." + : `Failed to execute OMP CLI health check: ${versionProbe.failure instanceof Error ? versionProbe.failure.message : String(versionProbe.failure)}.`, + }); + } + if (Option.isNone(versionProbe.success)) { + return buildFailure({ + installed: true, + message: "OMP CLI timed out while running `omp --version`.", + }); + } + const versionResult = versionProbe.success.value; + if (versionResult.code !== 0) { + return buildFailure({ + installed: true, + message: commandFailureMessage("--version", versionResult), + }); + } + const version = parseGenericCliVersion(`${versionResult.stdout}\n${versionResult.stderr}`); + if (!version) { + return buildFailure({ + installed: true, + message: "Unable to determine the installed OMP version from `omp --version`.", + }); + } + if (compareSemverVersions(version, MINIMUM_OMP_ACP_VERSION) < 0) { + return buildFailure({ + installed: true, + version, + message: `OMP v${version} is too old. T3 Code requires v${MINIMUM_OMP_ACP_VERSION} or newer (ACP + profiles). Run \`omp update\`.`, + }); + } + + const modelsArgs = buildOmpCliArgs(settings, ["models", "--json"]); + const modelsProbe = yield* runOmpCommand(settings, modelsArgs, environment).pipe( + Effect.timeoutOption(OMP_MODELS_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(modelsProbe)) { + return buildFailure({ + installed: true, + version, + status: fallbackModels.length > 0 ? "warning" : "error", + message: `Failed to discover OMP models: ${modelsProbe.failure instanceof Error ? modelsProbe.failure.message : String(modelsProbe.failure)}.`, + }); + } + if (Option.isNone(modelsProbe.success)) { + return buildFailure({ + installed: true, + version, + status: fallbackModels.length > 0 ? "warning" : "error", + message: "OMP model discovery timed out while running `omp models --json`.", + }); + } + const modelsResult = modelsProbe.success.value; + if (modelsResult.code !== 0) { + return buildFailure({ + installed: true, + version, + status: fallbackModels.length > 0 ? "warning" : "error", + message: commandFailureMessage("models --json", modelsResult), + }); + } + const parsedModels = parseOmpModelsJson(modelsResult.stdout); + if (!parsedModels) { + return buildFailure({ + installed: true, + version, + status: fallbackModels.length > 0 ? "warning" : "error", + message: "OMP returned invalid JSON from `omp models --json`.", + }); + } + + const discoveredModels = buildOmpDiscoveredModels(parsedModels); + const models = providerModelsFromSettings( + discoveredModels, + settings.customModels, + EMPTY_CAPABILITIES, + ); + const hasDiscoveredModel = discoveredModels.length > 0; + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: hasDiscoveredModel ? "ready" : models.length > 0 ? "warning" : "error", + auth: hasDiscoveredModel ? { status: "authenticated" } : { status: "unauthenticated" }, + ...(!hasDiscoveredModel + ? { + message: + models.length > 0 + ? "OMP model discovery found no authenticated models; only custom models are available." + : "OMP has no authenticated models. Run `omp` and use `/login`, or configure a provider API key.", + } + : {}), + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dcc3ac0b5db7..fa96bf71c219 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `omp`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -30,6 +30,7 @@ import { type CursorSettings, type GrokSettings, type OpenCodeSettings, + type OmpSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { OmpDriver } from "../Drivers/OmpDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -133,6 +135,14 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeOmpConfig = (overrides: Partial): OmpSettings => ({ + enabled: false, + binaryPath: "omp", + profile: "", + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -295,12 +305,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const ompId = ProviderInstanceId.make("omp_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const ompDriverKind = ProviderDriverKind.make("omp"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -336,10 +348,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [ompId]: { + driver: ompDriverKind, + displayName: "OMP", + enabled: false, + config: makeOmpConfig({ profile: "work" }), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver, OmpDriver], configMap, }); @@ -349,9 +367,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, ompId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -362,16 +380,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const omp = yield* registry.getInstance(ompId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(omp?.driverKind).toBe(ompDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(omp?.displayName).toBe("OMP"); // Every instance owns its own set of closures — no sharing across // drivers. `adapter` / `textGeneration` / `snapshot` are all @@ -384,6 +405,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.adapter, grok!.adapter, openCode!.adapter, + omp!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -392,6 +414,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + omp!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -400,6 +423,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + omp!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -442,6 +466,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(openCodeSnapshot.continuation?.groupKey).toBe( `${openCodeDriverKind}:instance:${openCodeId}`, ); + + const ompSnapshot = yield* omp!.snapshot.getSnapshot; + expect(ompSnapshot.instanceId).toBe(ompId); + expect(ompSnapshot.driver).toBe(ompDriverKind); + expect(ompSnapshot.enabled).toBe(false); + expect(ompSnapshot.continuation?.groupKey).toBe(`${ompDriverKind}:instance:${ompId}`); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a3..0d79661828c3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1443,6 +1443,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals @@ -1555,6 +1556,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, }), ), @@ -1669,6 +1671,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, providerInstances: { ghost_main: { @@ -1738,6 +1741,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + omp: { + enabled: false, + }, }, }), ), @@ -1807,6 +1813,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "omp", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts new file mode 100644 index 000000000000..deda46f66071 --- /dev/null +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -0,0 +1,7 @@ +/** + * OmpAdapter — per-instance adapter shape for OMP's ACP runtime. + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OmpAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index 0aebe0ca6d82..a832cb115ced 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -6,9 +6,21 @@ import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport. describe("AcpAdapterSupport", () => { it("maps ACP approval decisions to permission outcomes", () => { - expect(acpPermissionOutcome("accept")).toBe("allow-once"); - expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always"); - expect(acpPermissionOutcome("decline")).toBe("reject-once"); + const options = [ + { optionId: "once", name: "Allow once", kind: "allow_once" as const }, + { optionId: "always", name: "Always allow", kind: "allow_always" as const }, + { optionId: "no", name: "Reject", kind: "reject_once" as const }, + ]; + expect(acpPermissionOutcome("accept", options)).toBe("once"); + expect(acpPermissionOutcome("acceptForSession", options)).toBe("always"); + expect(acpPermissionOutcome("decline", options)).toBe("no"); + }); + + it("falls back to reject_always when reject_once is unavailable", () => { + const options = [ + { optionId: "always-reject", name: "Always reject", kind: "reject_always" as const }, + ]; + expect(acpPermissionOutcome("decline", options)).toBe("always-reject"); }); it("maps ACP request errors to provider adapter request errors", () => { diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd99..3968f5d27c7c 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { ProviderAdapterRequestError, @@ -43,14 +44,24 @@ export function mapAcpToAdapterError( }); } -export function acpPermissionOutcome(decision: ProviderApprovalDecision): string { - switch (decision) { - case "acceptForSession": - return "allow-always"; - case "accept": - return "allow-once"; - case "decline": - default: - return "reject-once"; +export function acpPermissionOutcome( + decision: ProviderApprovalDecision, + options: ReadonlyArray, +): string | undefined { + const desiredKinds = + decision === "acceptForSession" + ? (["allow_always"] as const) + : decision === "accept" + ? (["allow_once"] as const) + : (["reject_once", "reject_always"] as const); + for (const kind of desiredKinds) { + const byKind = options.find((option) => option.kind === kind)?.optionId; + if (byKind?.trim()) return byKind; } + + // Some pre-standard ACP agents used kebab-case ids while newer agents, + // including OMP, use snake_case ids. `kind` is authoritative, but retain + // both id spellings for agents that omit it. + const aliases = new Set(desiredKinds.flatMap((kind) => [kind, kind.replaceAll("_", "-")])); + return options.find((option) => aliases.has(option.optionId))?.optionId; } diff --git a/apps/server/src/provider/acp/AcpElicitation.test.ts b/apps/server/src/provider/acp/AcpElicitation.test.ts new file mode 100644 index 000000000000..2d28f199545a --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildAcpElicitationForm } from "./AcpElicitation.ts"; + +describe("AcpElicitation", () => { + it("maps labeled selects and booleans back to ACP form values", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose how to continue", + requestedSchema: { + type: "object", + required: ["strategy", "confirmed"], + properties: { + strategy: { + type: "string", + title: "Strategy", + oneOf: [ + { const: "safe", title: "Safe route" }, + { const: "fast", title: "Fast route" }, + ], + }, + confirmed: { + type: "boolean", + title: "Confirm", + description: "Proceed with this strategy?", + }, + }, + }, + }); + + expect(form?.questions).toEqual([ + { + id: "strategy", + header: "Strategy", + question: "Strategy", + options: [ + { label: "Safe route", description: "safe" }, + { label: "Fast route", description: "fast" }, + ], + }, + { + id: "confirmed", + header: "Confirm", + question: "Proceed with this strategy?", + options: [ + { label: "Yes", description: "true" }, + { label: "No", description: "false" }, + ], + }, + ]); + expect( + form?.resolve({ + strategy: "Safe route", + confirmed: "Yes", + }), + ).toEqual({ + action: { + action: "accept", + content: { strategy: "safe", confirmed: true }, + }, + }); + }); + + it("keeps duplicate choice titles distinguishable", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose a route", + requestedSchema: { + required: ["route"], + properties: { + route: { + type: "string", + oneOf: [ + { const: "first", title: "Same label" }, + { const: "second", title: "Same label" }, + ], + }, + }, + }, + }); + + expect(form?.questions[0]?.options).toEqual([ + { label: "Same label", description: "first" }, + { label: "Same label (2)", description: "second" }, + ]); + expect(form?.resolve({ route: "Same label (2)" })).toEqual({ + action: { action: "accept", content: { route: "second" } }, + }); + }); + + it("accepts defaults for required optionless properties", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose a route", + requestedSchema: { + required: ["route"], + properties: { + route: { type: "string", default: "default-route" }, + strategy: { type: "string", enum: ["safe", "fast"] }, + }, + }, + }); + + expect(form?.resolve({ strategy: "safe" })).toEqual({ + action: { + action: "accept", + content: { route: "default-route", strategy: "safe" }, + }, + }); + }); + + it("cancels optionless forms without questions", () => { + expect( + buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Provide context", + requestedSchema: { + properties: { notes: { type: "string" } }, + }, + }), + ).toBeUndefined(); + }); + + it("supports multi-select answers", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Select targets", + requestedSchema: { + required: ["targets"], + properties: { + targets: { + type: "array", + description: "Select targets", + items: { + anyOf: [ + { const: "web", title: "Web app" }, + { const: "server", title: "Server" }, + ], + }, + }, + }, + }, + }); + + expect(form?.questions[0]?.multiSelect).toBe(true); + expect( + form?.resolve({ + targets: ["Web app", "Server"], + }), + ).toEqual({ + action: { + action: "accept", + content: { targets: ["web", "server"] }, + }, + }); + }); + + it("rejects forms containing required optionless free-form or numeric properties", () => { + expect( + buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Name the change", + requestedSchema: { + required: ["name"], + properties: { name: { type: "string" } }, + }, + }), + ).toBeUndefined(); + expect( + buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose and explain", + requestedSchema: { + required: ["strategy", "count"], + properties: { + strategy: { type: "string", enum: ["safe", "fast"] }, + count: { type: "integer" }, + }, + }, + }), + ).toBeUndefined(); + }); + + it("omits optional optionless properties from the form", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose a strategy", + requestedSchema: { + required: ["strategy"], + properties: { + strategy: { type: "string", enum: ["safe", "fast"] }, + notes: { type: "string" }, + }, + }, + }); + + expect(form?.questions).toHaveLength(1); + expect(form?.questions[0]?.id).toBe("strategy"); + }); + + it("cancels a missing required choice and ignores URL mode", () => { + const form = buildAcpElicitationForm({ + mode: "form", + sessionId: "session-1", + message: "Choose a strategy", + requestedSchema: { + required: ["strategy"], + properties: { strategy: { type: "string", enum: ["safe", "fast"] } }, + }, + }); + + expect(form?.resolve({})).toEqual({ + action: { action: "cancel" }, + }); + expect( + buildAcpElicitationForm({ + mode: "url", + sessionId: "session-1", + elicitationId: "auth-1", + message: "Open a browser", + url: "https://example.com", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/acp/AcpElicitation.ts b/apps/server/src/provider/acp/AcpElicitation.ts new file mode 100644 index 000000000000..ceaa539c8391 --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.ts @@ -0,0 +1,206 @@ +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +type FormElicitationRequest = Extract< + EffectAcpSchema.ElicitationRequest, + { readonly mode: "form" } +>; +type ElicitationProperty = EffectAcpSchema.ElicitationPropertySchema; +type ElicitationValue = EffectAcpSchema.ElicitationContentValue; + +interface ElicitationChoice { + readonly label: string; + readonly value: string; +} + +export interface AcpElicitationForm { + readonly questions: ReadonlyArray; + readonly resolve: (answers: ProviderUserInputAnswers) => EffectAcpSchema.ElicitationResponse; +} + +function nonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function withUniqueLabels( + choices: ReadonlyArray, +): ReadonlyArray { + const counts = new Map(); + const used = new Set(); + return choices.map((choice) => { + let occurrence = counts.get(choice.label) ?? 0; + let label = choice.label; + do { + occurrence += 1; + label = occurrence === 1 ? choice.label : `${choice.label} (${occurrence})`; + } while (used.has(label)); + counts.set(choice.label, occurrence); + used.add(label); + return label === choice.label ? choice : { ...choice, label }; + }); +} + +function choicesForProperty(property: ElicitationProperty): ReadonlyArray { + if (property.type === "string") { + if (property.oneOf && property.oneOf.length > 0) { + return withUniqueLabels( + property.oneOf.map((option) => ({ + label: nonEmpty(option.title) ?? option.const, + value: option.const, + })), + ); + } + return withUniqueLabels((property.enum ?? []).map((value) => ({ label: value, value }))); + } + if (property.type === "boolean") { + return [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ]; + } + if (property.type === "array") { + if ("anyOf" in property.items) { + return withUniqueLabels( + property.items.anyOf.map((option) => ({ + label: nonEmpty(option.title) ?? option.const, + value: option.const, + })), + ); + } + return withUniqueLabels(property.items.enum.map((value) => ({ label: value, value }))); + } + return []; +} + +function questionForProperty(input: { + readonly request: FormElicitationRequest; + readonly id: string; + readonly property: ElicitationProperty; + readonly propertyCount: number; +}): UserInputQuestion { + const { request, id, property, propertyCount } = input; + const choices = choicesForProperty(property); + const propertyTitle = nonEmpty(property.title); + const formTitle = nonEmpty(request.requestedSchema.title); + const requestMessage = nonEmpty(request.message) ?? "The agent needs additional input."; + const propertyDescription = nonEmpty(property.description); + + return { + id, + header: propertyTitle ?? formTitle ?? "Question", + question: propertyDescription ?? (propertyCount === 1 ? requestMessage : (propertyTitle ?? id)), + options: choices.map((choice) => ({ + label: choice.label, + description: choice.label === choice.value ? choice.label : choice.value, + })), + ...(property.type === "array" ? { multiSelect: true } : {}), + }; +} + +function firstAnswer(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +function labelToValue(property: ElicitationProperty, raw: string): string { + const choice = choicesForProperty(property).find((candidate) => candidate.label === raw); + return choice?.value ?? raw; +} + +function resolvePropertyValue( + property: ElicitationProperty, + rawAnswer: unknown, +): ElicitationValue | undefined { + if (rawAnswer === undefined || rawAnswer === null) { + return property.default ?? undefined; + } + + switch (property.type) { + case "string": { + const raw = firstAnswer(rawAnswer); + if (typeof raw !== "string") return undefined; + return labelToValue(property, raw); + } + case "boolean": { + const raw = firstAnswer(rawAnswer); + if (typeof raw === "boolean") return raw; + if (typeof raw !== "string") return undefined; + const normalized = labelToValue(property, raw).trim().toLowerCase(); + if (normalized === "true" || normalized === "yes") return true; + if (normalized === "false" || normalized === "no") return false; + return undefined; + } + case "integer": + case "number": { + const raw = firstAnswer(rawAnswer); + const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; + if (!Number.isFinite(parsed)) return undefined; + if (property.type === "integer" && !Number.isInteger(parsed)) return undefined; + return parsed; + } + case "array": { + const rawValues = Array.isArray(rawAnswer) ? rawAnswer : [rawAnswer]; + if (!rawValues.every((value) => typeof value === "string")) return undefined; + return rawValues.map((value) => labelToValue(property, value)); + } + } +} +/** + * Project an ACP form elicitation onto T3 Code's provider-neutral structured + * question surface. URL elicitations are intentionally not handled because + * the client advertises only `elicitation.form` support. + */ +export function buildAcpElicitationForm( + request: EffectAcpSchema.ElicitationRequest, +): AcpElicitationForm | undefined { + if (request.mode !== "form") return undefined; + + const propertyEntries = Object.entries(request.requestedSchema.properties ?? {}); + if (propertyEntries.length === 0) return undefined; + const required = new Set(request.requestedSchema.required ?? []); + // T3's structured user-input surface currently renders selectable options + // only. A required optionless question cannot be answered, so cancel the + // whole form. Optional optionless properties are omitted and resolve as + // absent. + if ( + propertyEntries.some( + ([id, property]) => + required.has(id) && + property.default === undefined && + choicesForProperty(property).length === 0, + ) + ) { + return undefined; + } + + const questionEntries = propertyEntries.filter( + ([, property]) => choicesForProperty(property).length > 0, + ); + if (questionEntries.length === 0) return undefined; + const questions = questionEntries.map(([id, property]) => + questionForProperty({ + request, + id, + property, + propertyCount: questionEntries.length, + }), + ); + + return { + questions, + resolve: (answers) => { + const content: Record = {}; + for (const [id, property] of propertyEntries) { + const value = resolvePropertyValue(property, answers[id]); + if (value === undefined) { + if (required.has(id)) { + return { action: { action: "cancel" } }; + } + continue; + } + content[id] = value; + } + return { action: { action: "accept", content } }; + }, + }; +} diff --git a/apps/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts new file mode 100644 index 000000000000..5a980a248c1d --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -0,0 +1,108 @@ +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect } from "vite-plus/test"; +import { it } from "@effect/vitest"; + +import { applyOmpAcpModelSelection, buildOmpAcpSpawnInput } from "./OmpAcpSupport.ts"; + +describe("buildOmpAcpSpawnInput", () => { + it("maps T3 runtime modes to explicit OMP approval behavior", () => { + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "approval-required")).toEqual({ + command: "omp", + args: ["acp", "--approval-mode", "always-ask"], + cwd: "/tmp/project", + }); + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "auto-accept-edits")).toEqual({ + command: "omp", + args: ["acp", "--approval-mode", "write"], + cwd: "/tmp/project", + }); + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "auto")).toEqual({ + command: "omp", + args: ["acp", "--approval-mode", "write"], + cwd: "/tmp/project", + }); + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", "full-access")).toEqual({ + command: "omp", + args: ["acp", "--approval-mode", "yolo"], + cwd: "/tmp/project", + }); + }); + + it("uses the configured binary, profile, and environment", () => { + const environment = { OMP_TEST: "1" }; + expect( + buildOmpAcpSpawnInput( + { binaryPath: "/opt/omp/bin/omp", profile: "work" }, + "/tmp/project", + "full-access", + environment, + ), + ).toEqual({ + command: "/opt/omp/bin/omp", + args: ["acp", "--profile", "work", "--approval-mode", "yolo"], + cwd: "/tmp/project", + env: environment, + }); + }); +}); + +describe("applyOmpAcpModelSelection", () => { + it.effect("sets the exact model before applying options advertised by that model", () => + Effect.gen(function* () { + const calls: Array< + | { readonly type: "model"; readonly value: string } + | { readonly type: "config"; readonly configId: string; readonly value: string | boolean } + > = []; + let configOptions: ReadonlyArray = []; + const runtime = { + getConfigOptions: Effect.sync(() => configOptions), + setModel: (value: string) => + Effect.sync(() => { + calls.push({ type: "model", value }); + configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: value, + options: [{ value, name: value }], + }, + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, + ]; + }), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ type: "config", configId, value }); + }), + }; + + yield* applyOmpAcpModelSelection({ + runtime, + model: "openai-codex/gpt-5.5", + selections: [ + { id: "thinking", value: "high" }, + { id: "stale-option", value: true }, + { id: "model", value: "must-not-be-replayed" }, + ], + mapError: ({ cause }) => cause.message, + }); + + expect(calls).toEqual([ + { type: "model", value: "openai-codex/gpt-5.5" }, + { type: "config", configId: "thinking", value: "high" }, + ]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts new file mode 100644 index 000000000000..53de4a3e1059 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -0,0 +1,152 @@ +import { + type OmpSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { findSessionConfigOption } from "./AcpRuntimeModel.ts"; + +type AcpSpawnInput = AcpSessionRuntime.AcpSpawnInput; +type AcpSessionRuntimeShape = AcpSessionRuntime.AcpSessionRuntime["Service"]; + +type OmpAcpRuntimeSettings = Pick; + +export interface OmpAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly ompSettings: OmpAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + /** + * Text-generation and other non-session callers default to full access so + * a headless OMP child cannot deadlock on an unhandled approval request. + */ + readonly runtimeMode?: RuntimeMode; +} + +export interface OmpAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +function approvalModeArgs(runtimeMode: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "approval-required": + return ["--approval-mode", "always-ask"]; + case "auto-accept-edits": + case "auto": + // OMP has no AI-review approval mode; "auto" degrades to auto-accept + // edits while still surfacing execution approvals to the user. + return ["--approval-mode", "write"]; + case "full-access": + return ["--approval-mode", "yolo"]; + } +} + +export function buildOmpAcpSpawnInput( + ompSettings: OmpAcpRuntimeSettings | null | undefined, + cwd: string, + runtimeMode: RuntimeMode = "full-access", + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + const profile = ompSettings?.profile?.trim(); + return { + command: ompSettings?.binaryPath || "omp", + args: ["acp", ...(profile ? ["--profile", profile] : []), ...approvalModeArgs(runtimeMode)], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeOmpAcpRuntime = ( + input: OmpAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildOmpAcpSpawnInput( + input.ompSettings, + input.cwd, + input.runtimeMode ?? "full-access", + input.environment, + ), + authMethodId: "agent", + clientCapabilities: { + elicitation: { form: {} }, + }, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +interface OmpAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +/** + * Apply OMP's exact ACP model id, then replay provider-option selections that + * still exist for that model. OMP refreshes `configOptions` after a model + * switch, so stale options from a previous model are safely ignored. + */ +export function applyOmpAcpModelSelection(input: { + readonly runtime: OmpAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: OmpAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const model = input.model?.trim(); + if (model) { + yield* input.runtime.setModel(model).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-model", + }), + ), + ); + } + + const configOptions = yield* input.runtime.getConfigOptions; + for (const selection of input.selections ?? []) { + const option = findSessionConfigOption(configOptions, selection.id); + if (!option || option.category === "model" || option.category === "mode") { + continue; + } + yield* input.runtime.setConfigOption(option.id, selection.value).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: option.id, + }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..5300e6908a85 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OmpDriver, type OmpDriverEnv } from "./Drivers/OmpDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | OmpDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("does not invent a static model selector when only OMP is enabled", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const provisionalModel = "gpt-5.4-mini"; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + grok: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + omp: { enabled: true }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: provisionalModel, + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: ProviderInstanceId.make("omp"), + model: provisionalModel, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); it.effect("drops stale text generation options when resetting model selection", () => Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2798faf6f006..1f3373c675b8 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,7 +11,6 @@ * @module ServerSettings */ import { - DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, @@ -199,10 +198,16 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting ...settings, textGenerationModelSelection: { instanceId: ProviderInstanceId.make(fallback), + // Some providers (notably OMP) expose a credential-dependent model + // inventory and therefore have no universally valid static default. + // Keep the previous model as a provisional value for those providers; + // the text-generation router resolves it against the selected + // instance's live snapshot before dispatch. This avoids manufacturing a + // provider/model selector that the provider is guaranteed to reject. model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_TEXT_GENERATION_MODEL, + settings.textGenerationModelSelection.model, } satisfies ModelSelection, }; } diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts new file mode 100644 index 000000000000..b3d357e56001 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -0,0 +1,129 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { OmpSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { expect } from "vite-plus/test"; + +import { ServerConfig } from "../config.ts"; +import { makeOmpTextGeneration } from "./OmpTextGeneration.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const decodeOmpSettings = Schema.decodeSync(OmpSettings); +const encodeUnknownJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeRequestLogEntry = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + method: Schema.optionalKey(Schema.String), + params: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); +const OmpTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-omp-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function makeOmpWrapper(dir: string, env: Record): string { + const binDir = NodePath.join(dir, "bin"); + const ompPath = NodePath.join(binDir, "omp"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( + ompPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec bun ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(ompPath, 0o755); + return ompPath; +} + +it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { + it.effect("uses the exact OMP provider/model selector for structured generation", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const model = "openai-codex/gpt-5.4"; + const ompPath = makeOmpWrapper(tempDir, { + T3_ACP_EXTRA_MODEL_ID: model, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: encodeUnknownJson({ + subject: "Add OMP structured generation", + body: "- select the exact provider model", + }), + }); + const textGeneration = yield* makeOmpTextGeneration( + decodeOmpSettings({ binaryPath: ompPath }), + ); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/omp-text-generation", + stagedSummary: "M apps/server/src/textGeneration/OmpTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model, + }, + }); + + expect(generated).toEqual({ + subject: "Add OMP structured generation", + body: "- select the exact provider model", + }); + + const requests = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => decodeRequestLogEntry(line)); + expect(requests.find((request) => request.method === "authenticate")?.params).toMatchObject({ + methodId: "agent", + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === model, + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts new file mode 100644 index 000000000000..2bb169e225e9 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -0,0 +1,232 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { TextGenerationError, type ModelSelection, type OmpSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../provider/acp/OmpAcpSupport.ts"; + +const OMP_TIMEOUT_MS = 180_000; +const isTextGenerationError = Schema.is(TextGenerationError); + +/** Build an OMP text-generation closure bound to one provider instance. */ +export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function* ( + ompSettings: OmpSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runOmpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeOmpAcpRuntime({ + ompSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + runtimeMode: "full-access", + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") return Effect.void; + const content = update.content; + if (content.type !== "text") return Effect.void; + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyOmpAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + new TextGenerationError({ + operation, + detail: + step === "set-config-option" + ? `Failed to set OMP ACP config option "${configId}" for text generation.` + : "Failed to set OMP ACP model for text generation.", + cause, + }), + }); + return yield* runtime.prompt({ prompt: [{ type: "text", text: prompt }] }); + }).pipe( + Effect.timeoutOption(OMP_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new TextGenerationError({ operation, detail: "OMP request timed out." })), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ operation, detail: "OMP ACP request failed.", cause }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "OMP ACP request was cancelled." + : "OMP returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "OMP returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "OMP ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OmpTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runOmpJson({ + 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: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OmpTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runOmpJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OmpTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOmpJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OmpTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runOmpJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5b..5b23f8a42da3 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -5,7 +5,12 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; @@ -27,21 +32,44 @@ const makeStubTextGeneration = ( const makeStubInstance = ( instanceId: ProviderInstanceId, textGeneration: TextGeneration.TextGeneration["Service"], + options?: { + readonly driverKind?: ProviderDriverKind; + readonly currentModels?: ReadonlyArray; + readonly refreshedModels?: ReadonlyArray; + readonly onRefresh?: () => void; + }, ): ProviderInstance => ({ instanceId, - driverKind: instanceId as unknown as ProviderInstance["driverKind"], + driverKind: options?.driverKind ?? (instanceId as unknown as ProviderInstance["driverKind"]), continuationIdentity: { - driverKind: instanceId as unknown as ProviderInstance["driverKind"], + driverKind: options?.driverKind ?? (instanceId as unknown as ProviderInstance["driverKind"]), continuationKey: `${instanceId}:test`, }, displayName: undefined, enabled: true, - snapshot: {} as ProviderInstance["snapshot"], + snapshot: { + maintenanceCapabilities: {} as ProviderInstance["snapshot"]["maintenanceCapabilities"], + getSnapshot: Effect.succeed({ models: options?.currentModels ?? [] } as ServerProvider), + refresh: Effect.sync(() => { + options?.onRefresh?.(); + return { + models: options?.refreshedModels ?? options?.currentModels ?? [], + } as ServerProvider; + }), + streamChanges: Stream.empty, + }, adapter: {} as ProviderInstance["adapter"], textGeneration, }) satisfies ProviderInstance; +const serverModel = (slug: string, isCustom = false): ServerProviderModel => ({ + slug, + name: slug, + isCustom, + capabilities: null, +}); + const makeStubRegistry = ( instances: ReadonlyArray, ): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => { @@ -118,4 +146,112 @@ describe("makeTextGenerationFromRegistry", () => { } }), ); + + it.effect("resolves a provisional OMP model from the refreshed live inventory", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("omp"); + let refreshCount = 0; + let dispatchedSelection: + | Parameters< + TextGeneration.TextGeneration["Service"]["generateBranchName"] + >[0]["modelSelection"] + | undefined; + const instance = makeStubInstance( + instanceId, + makeStubTextGeneration({ + generateBranchName: (input) => { + dispatchedSelection = input.modelSelection; + return Effect.succeed({ branch: "omp-branch" }); + }, + }), + { + driverKind: ProviderDriverKind.make("omp"), + currentModels: [], + refreshedModels: [ + serverModel("anthropic/claude-sonnet-4-6"), + serverModel("local/qwen", true), + ], + onRefresh: () => { + refreshCount += 1; + }, + }, + ); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance])); + + const result = yield* tg.generateBranchName({ + cwd: process.cwd(), + message: "Use the live OMP model inventory", + modelSelection: createModelSelection(instanceId, "gpt-5.4-mini", [ + { id: "thinking", value: "high" }, + ]), + }); + + expect(result.branch).toBe("omp-branch"); + expect(refreshCount).toBe(1); + expect(dispatchedSelection).toEqual({ + instanceId, + model: "anthropic/claude-sonnet-4-6", + }); + }), + ); + + it.effect("fails clearly when OMP refresh still exposes no models", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("omp"); + const instance = makeStubInstance(instanceId, makeStubTextGeneration({}), { + driverKind: ProviderDriverKind.make("omp"), + currentModels: [], + refreshedModels: [], + }); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance])); + + const result = yield* tg + .generateBranchName({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection(instanceId, "gpt-5.4-mini"), + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure.operation).toBe("generateBranchName"); + expect(result.failure.detail).toContain("has no available models"); + } + }), + ); + it.effect("refreshes a non-empty OMP cache when the requested model is missing", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("omp"); + let refreshCount = 0; + let dispatchedModel: string | undefined; + const instance = makeStubInstance( + instanceId, + makeStubTextGeneration({ + generateBranchName: (input) => { + dispatchedModel = input.modelSelection.model; + return Effect.succeed({ branch: "omp-branch" }); + }, + }), + { + driverKind: ProviderDriverKind.make("omp"), + currentModels: [serverModel("stale/model")], + refreshedModels: [serverModel("new/model")], + onRefresh: () => { + refreshCount += 1; + }, + }, + ); + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance])); + + yield* tg.generateBranchName({ + cwd: process.cwd(), + message: "Use the refreshed OMP model", + modelSelection: createModelSelection(instanceId, "new/model"), + }); + + expect(refreshCount).toBe(1); + expect(dispatchedModel).toBe("new/model"); + }), + ); }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..d2b5c72dad47 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,14 +1,22 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; -import { TextGenerationError } from "@t3tools/contracts"; +import { + ProviderDriverKind, + TextGenerationError, + type ChatAttachment, + type ModelSelection, + type ProviderInstanceId, +} from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +/** Any registered provider driver can supply the required text-generation shape. */ +export type TextGenerationProvider = ProviderDriverKind; + +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); export interface CommitMessageGenerationInput { cwd: string; @@ -129,11 +137,11 @@ const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], operation: TextGenerationOp, instanceId: ProviderInstanceId, -): Effect.Effect => +): Effect.Effect => registry.getInstance(instanceId).pipe( Effect.flatMap((instance) => instance - ? Effect.succeed(instance.textGeneration) + ? Effect.succeed(instance) : Effect.fail( new TextGenerationError({ operation, @@ -143,25 +151,103 @@ const resolveInstance = ( ), ); +/** + * OMP's available selectors depend on the credentials and provider + * configuration of each instance, so no static fallback can be valid for + * every installation. Resolve provisional or stale OMP selections at the + * dispatch boundary, where both the live model snapshot and text-generation + * closure are available without introducing a settings/registry cycle. + */ +const resolveOmpModelSelection = ( + instance: ProviderInstance, + operation: TextGenerationOp, + selection: ModelSelection, +): Effect.Effect => { + if (instance.driverKind !== OMP_DRIVER_KIND) { + return Effect.succeed(selection); + } + + return Effect.gen(function* () { + const current = yield* instance.snapshot.getSnapshot; + if (current.models.some((model) => model.slug === selection.model)) { + return selection; + } + + // Refresh whenever the requested selector is absent. A non-empty cache can + // still be stale after switching to a newly discovered OMP model. + const resolvedSnapshot = yield* instance.snapshot.refresh; + if (resolvedSnapshot.models.some((model) => model.slug === selection.model)) { + return selection; + } + + const fallback = + resolvedSnapshot.models.find((model) => !model.isCustom) ?? resolvedSnapshot.models[0]; + if (!fallback) { + return yield* new TextGenerationError({ + operation, + detail: `OMP provider instance '${instance.instanceId}' has no available models for text generation. Configure OMP credentials or add a custom model, then refresh providers.`, + }); + } + + // Provider options belong to the stale model and may not exist on the + // discovered fallback. Do not send them through OMP's strict ACP option + // validation. + return { + instanceId: instance.instanceId, + model: fallback.slug, + } satisfies ModelSelection; + }); +}; + +const resolveTextGenerationTarget = ( + registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], + operation: TextGenerationOp, + selection: ModelSelection, +): Effect.Effect< + { + readonly textGeneration: ProviderInstance["textGeneration"]; + readonly modelSelection: ModelSelection; + }, + TextGenerationError +> => + resolveInstance(registry, operation, selection.instanceId).pipe( + Effect.flatMap((instance) => + resolveOmpModelSelection(instance, operation, selection).pipe( + Effect.map((modelSelection) => ({ + textGeneration: instance.textGeneration, + modelSelection, + })), + ), + ), + ); + export const makeTextGenerationFromRegistry = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], ): TextGeneration["Service"] => TextGeneration.of({ generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + resolveTextGenerationTarget(registry, "generateCommitMessage", input.modelSelection).pipe( + Effect.flatMap(({ textGeneration, modelSelection }) => + textGeneration.generateCommitMessage({ ...input, modelSelection }), + ), ), generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + resolveTextGenerationTarget(registry, "generatePrContent", input.modelSelection).pipe( + Effect.flatMap(({ textGeneration, modelSelection }) => + textGeneration.generatePrContent({ ...input, modelSelection }), + ), ), generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + resolveTextGenerationTarget(registry, "generateBranchName", input.modelSelection).pipe( + Effect.flatMap(({ textGeneration, modelSelection }) => + textGeneration.generateBranchName({ ...input, modelSelection }), + ), ), generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + resolveTextGenerationTarget(registry, "generateThreadTitle", input.modelSelection).pipe( + Effect.flatMap(({ textGeneration, modelSelection }) => + textGeneration.generateThreadTitle({ ...input, modelSelection }), + ), ), }); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..e6c4d41dd178 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -663,6 +663,25 @@ export const OpenCodeIcon: Icon = (props) => ( ); +/** Adapted from Oh My Pi's official `assets/icon.svg`. */ +export const OmpIcon: Icon = ({ className, ...props }) => ( + + + + + + + + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("omp")]: OmpIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 5bd4fdc08c4a..9290ff6cb188 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode|cursor|omp)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7ae26b278657..46fa2b8288b3 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial { }); }); + it("derives OMP binary and profile fields from its settings schema", () => { + const omp = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("omp")]; + + expect(omp).toBeDefined(); + expect(deriveProviderSettingsFields(omp!)).toMatchObject([ + { + key: "binaryPath", + label: "Binary path", + placeholder: "omp", + }, + { + key: "profile", + label: "Profile", + placeholder: "e.g. work", + }, + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..fc5c49ad99fc 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,12 @@ import { CodexSettings, CursorSettings, GrokSettings, + OmpSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OmpIcon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +68,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("omp"), + label: "OMP", + icon: OmpIcon, + settingsSchema: OmpSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c127dfba175e..d0610e253a72 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -24,9 +24,11 @@ const CODEX_INSTANCE = ProviderInstanceId.make("codex"); const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary"); const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); +const OMP_INSTANCE = ProviderInstanceId.make("omp"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OMP_DRIVER = ProviderDriverKind.make("omp"); type ProviderOptionSelectionBag = ReadonlyArray; type ProviderOptionSelectionsByProvider = Partial>; @@ -134,6 +136,18 @@ function resetComposerDraftStore() { }); } +function mergePersistedComposerDraftState(persistedState: unknown) { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + return persistApi.getOptions().merge(persistedState, useComposerDraftStore.getInitialState()); +} + function modelSelection( provider: ProviderDriverKind, model: string, @@ -446,38 +460,27 @@ describe("composerDraftStore terminal contexts", () => { }); it("hydrates persisted terminal contexts without in-memory snapshot text", () => { - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - merge: ( - persistedState: unknown, - currentState: ReturnType, - ) => ReturnType; - }; - }; - const mergedState = persistApi.getOptions().merge( - { - draftsByThreadId: { - [threadId]: { - prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - attachments: [], - terminalContexts: [ - { - id: "ctx-rehydrated", - threadId, - createdAt: "2026-03-13T12:00:00.000Z", - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 5, - }, - ], - }, + const mergedState = mergePersistedComposerDraftState({ + draftsByThreadId: { + [threadId]: { + prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, + attachments: [], + terminalContexts: [ + { + id: "ctx-rehydrated", + threadId, + createdAt: "2026-03-13T12:00:00.000Z", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 5, + }, + ], }, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectKey: {}, }, - useComposerDraftStore.getInitialState(), - ); + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectKey: {}, + }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([ { @@ -492,30 +495,19 @@ describe("composerDraftStore terminal contexts", () => { }); it("sanitizes malformed persisted drafts during merge", () => { - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - merge: ( - persistedState: unknown, - currentState: ReturnType, - ) => ReturnType; - }; - }; - const mergedState = persistApi.getOptions().merge( - { - draftsByThreadId: { - [threadId]: { - prompt: "", - attachments: "not-an-array", - terminalContexts: "not-an-array", - provider: "bogus-provider", - modelOptions: "not-an-object", - }, + const mergedState = mergePersistedComposerDraftState({ + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: "not-an-array", + terminalContexts: "not-an-array", + provider: "bogus-provider", + modelOptions: "not-an-object", }, - draftThreadsByThreadId: "not-an-object", - projectDraftThreadIdByProjectKey: "not-an-object", }, - useComposerDraftStore.getInitialState(), - ); + draftThreadsByThreadId: "not-an-object", + projectDraftThreadIdByProjectKey: "not-an-object", + }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); expect(mergedState.draftThreadsByThreadKey).toEqual({}); @@ -678,6 +670,33 @@ describe("composerDraftStore review comments", () => { ]); }); }); +describe("composerDraftStore legacy provider option migration", () => { + it("preserves OMP options and the exact provider/model selector", () => { + const threadId = ThreadId.make("thread-legacy-omp-options"); + const mergedState = mergePersistedComposerDraftState({ + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + provider: "omp", + model: "openai-codex/gpt-5.4", + modelOptions: { + omp: { thinking: "high" }, + }, + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectKey: {}, + }); + + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toMatchObject({ + modelSelectionByProvider: { + omp: modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4", { thinking: "high" }), + }, + activeProvider: OMP_INSTANCE, + }); + }); +}); describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); @@ -1440,6 +1459,17 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.activeProvider).toBe("claudeAgent"); }); + it("updates OMP options without a hard-coded provider allowlist", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection(threadRef, modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4")); + store.setModelOptions(threadRef, providerModelOptions({ omp: { thinking: "high" } })); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[OMP_INSTANCE]).toEqual( + modelSelection(OMP_DRIVER, "openai-codex/gpt-5.4", { thinking: "high" }), + ); + }); + it("creates the first sticky snapshot from provider option changes", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ebafd3b04d29..7efb752c40be 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -788,6 +788,31 @@ function coerceProviderOptionSelections( return undefined; } +/** + * Read a provider-keyed option bag without baking the currently installed + * drivers into draft persistence. `ProviderDriverKind` is deliberately open, + * so migrations and store updates must preserve any valid driver slug. + */ +function providerModelOptionEntries( + value: unknown, +): ReadonlyArray< + readonly [ProviderDriverKind, ReadonlyArray | undefined] +> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + const result: Array< + readonly [ProviderDriverKind, ReadonlyArray | undefined] + > = []; + for (const [rawProvider, rawSelections] of Object.entries(value)) { + const provider = normalizeProviderDriverKind(rawProvider); + if (provider) { + result.push([provider, coerceProviderOptionSelections(rawSelections)]); + } + } + return result; +} + /** * Normalize a per-provider options bag from either the v3 or legacy v2 shape. * @@ -800,12 +825,10 @@ function normalizeProviderModelOptions( provider?: ProviderDriverKind | null, legacy?: LegacyCodexFields, ): ProviderOptionSelectionsByProvider | null { - const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { - const selections = coerceProviderOptionSelections(candidate?.[providerKey]); + for (const [provider, selections] of providerModelOptionEntries(value)) { if (selections) { - result[providerKey] = selections; + result[provider] = selections; } } @@ -961,10 +984,8 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { - const options = modelOptions[provider]; + for (const [driverKind, options] of providerModelOptionEntries(modelOptions)) { if (options && options.length > 0) { - const driverKind = ProviderDriverKind.make(provider); const instanceKey = defaultInstanceIdForDriver(driverKind); result[instanceKey] = createModelSelection( instanceKey, @@ -2763,10 +2784,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { - if (!modelOptions || !(provider in modelOptions)) continue; - const opts = modelOptions[provider]; - const driverKind = ProviderDriverKind.make(provider); + for (const [driverKind, opts] of providerModelOptionEntries(modelOptions)) { const instanceKey = defaultInstanceIdForDriver(driverKind); const current = nextMap[instanceKey]; if (opts && opts.length > 0) { diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2d0d7bb02134..4702887e2211 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -130,6 +130,15 @@ describe("deriveProviderInstanceEntries", () => { expect(entry?.driverKind).toBe("codex"); expect(entry?.isDefault).toBe(false); }); + + it("uses the OMP brand label for its default instance", () => { + const [entry] = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("omp"), instanceId: "omp" }), + ]); + + expect(entry?.displayName).toBe("OMP"); + expect(entry?.isDefault).toBe(true); + }); }); describe("resolveSelectableProviderInstance", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..e6aa8f99a85f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("omp"), + label: "OMP", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..9e3eca2fe2ac 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and OMP. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..3514dca806c7 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ OpenCode, Oh My Pi │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, OpenCode, and OMP. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..6d7f478732ef 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -16,6 +16,7 @@ orchestration layer does not know which one is behind a thread. | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `omp` | [`Drivers/OmpDriver.ts`][omp] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[omp]: ../../apps/server/src/provider/Drivers/OmpDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..8d92d48bfe21 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -53,6 +53,7 @@ to use, then authenticate it. | Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | | Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | | OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Oh My Pi | [OMP](https://omp.sh) | `omp` | `omp` then `/login` | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..5851e45086ad 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -222,4 +223,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OMP_DRIVER_KIND]: "OMP", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 46705837afa4..8d49baaaa47e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -122,6 +122,12 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.omp).toEqual({ + enabled: true, + binaryPath: "omp", + profile: "", + customModels: [], + }); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { @@ -250,6 +256,10 @@ describe("ServerSettingsPatch string normalization", () => { homePath: " ~/.codex ", launchArgs: " --strict-config --enable foo ", }, + omp: { + binaryPath: " /opt/homebrew/bin/omp ", + profile: " work ", + }, }, providerInstances: { codex_personal: { @@ -266,6 +276,8 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); + expect(patch.providers?.omp?.binaryPath).toBe("/opt/homebrew/bin/omp"); + expect(patch.providers?.omp?.profile).toBe("work"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..6f89ebd3982c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -471,6 +471,41 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const OmpSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("omp").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the OMP binary used by this instance.", + providerSettingsForm: { placeholder: "omp", clearWhenEmpty: "omit" }, + }), + ), + profile: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Profile", + description: "OMP profile to use for this instance.", + providerSettingsForm: { + placeholder: "e.g. work", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "profile"], + }, +); +export type OmpSettings = typeof OmpSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -601,6 +636,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + omp: OmpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -704,6 +740,13 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OmpSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + profile: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -744,6 +787,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + omp: Schema.optionalKey(OmpSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual