diff --git a/README.md b/README.md index c439743cea54..24a2b78a5623 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon). +T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, OpenCode, and OMP, with more coming soon). ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, and OpenCode. +> T3 Code currently supports Codex, Claude, OpenCode, and OMP. > 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` > - 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 ### Run without installing diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e704b8d8a25a..5073cda0ee94 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,9 +18,12 @@ 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 failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const extraModelId = process.env.T3_ACP_EXTRA_MODEL_ID?.trim(); const sessionId = "mock-session-1"; let currentModeId = "ask"; @@ -178,6 +181,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 }] : []), ], }, ]; @@ -485,6 +489,47 @@ 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 (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" }; + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts new file mode 100644 index 000000000000..0a00d8ab2463 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -0,0 +1,164 @@ +/** + * OmpDriver — provider driver for the Oh My Pi (`omp`) ACP runtime. + */ +import { OmpSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +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 * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.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"; + +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 = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + 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(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingOmpProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).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.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index efef5f0a83b0..c7e179538a19 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -46,9 +46,13 @@ import { ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, + type ProviderAdapterError, } from "../Errors.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, +} from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, @@ -62,6 +66,7 @@ import { type AcpSessionModeState, parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; +import { buildAcpElicitationForm } from "../acp/AcpElicitation.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { @@ -73,12 +78,14 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import type * as EffectAcpErrors from "effect-acp/errors"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("cursor"); -const CURSOR_RESUME_VERSION = 1 as const; +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"]; @@ -88,7 +95,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { return Exit.isSuccess(result) ? result.value : undefined; } -export interface CursorAdapterLiveOptions { +export interface AcpProviderAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; @@ -100,7 +107,7 @@ export interface CursorAdapterLiveOptions { /** * 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. + * 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. @@ -108,7 +115,47 @@ export interface CursorAdapterLiveOptions { * 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; + readonly resolveSettings?: Effect.Effect; +} + +export type CursorAdapterLiveOptions = AcpProviderAdapterLiveOptions; + +interface AcpProviderRuntimeFactoryInput { + readonly settings: Settings; + readonly environment?: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cwd: string; + 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 cursorExtensions?: 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 { @@ -120,7 +167,7 @@ interface PendingUserInput { readonly answers: Deferred.Deferred; } -interface CursorSessionContext { +interface AcpSessionContext { readonly threadId: ThreadId; session: ProviderSession; readonly scope: Scope.Closeable; @@ -164,9 +211,9 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseCursorResume(raw: unknown): { sessionId: string } | undefined { +function parseAcpResume(raw: unknown): { sessionId: string } | undefined { if (!isRecord(raw)) return undefined; - if (raw.schemaVersion !== CURSOR_RESUME_VERSION) 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() }; } @@ -249,6 +296,11 @@ function applyRequestedSessionConfiguration(input: { 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"; @@ -256,15 +308,10 @@ function applyRequestedSessionConfiguration(input: { }): Effect.Effect { return Effect.gen(function* () { if (input.modelSelection) { - yield* applyCursorAcpModelSelection({ + yield* input.applyModelSelection({ runtime: input.runtime, model: input.modelSelection.model, selections: input.modelSelection.options, - mapError: ({ cause }) => - input.mapError({ - cause, - method: "session/set_config_option", - }), }); } @@ -304,12 +351,13 @@ function selectAutoApprovedPermissionOption( return undefined; } -export function makeCursorAdapter( - cursorSettings: CursorSettings, - options?: CursorAdapterLiveOptions, +export function makeAcpProviderAdapter( + definition: AcpProviderAdapterDefinition, ) { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); + 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; @@ -324,7 +372,7 @@ export function makeCursorAdapter( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const sessions = new Map(); + const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); @@ -360,7 +408,7 @@ export function makeCursorAdapter( threadId: ThreadId, method: string, payload: unknown, - _source: "acp.jsonrpc" | "acp.cursor.extension", + _source: "acp.jsonrpc" | `acp.${string}.extension`, ) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -383,7 +431,7 @@ export function makeCursorAdapter( }); const emitPlanUpdate = ( - ctx: CursorSessionContext, + ctx: AcpSessionContext, payload: { readonly explanation?: string | null; readonly plan: ReadonlyArray<{ @@ -392,7 +440,7 @@ export function makeCursorAdapter( }>; }, rawPayload: unknown, - source: "acp.jsonrpc" | "acp.cursor.extension", + source: "acp.jsonrpc" | `acp.${string}.extension`, method: string, ) => Effect.gen(function* () { @@ -417,7 +465,7 @@ export function makeCursorAdapter( const requireSession = ( threadId: ThreadId, - ): Effect.Effect => { + ): Effect.Effect => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( @@ -427,7 +475,7 @@ export function makeCursorAdapter( return Effect.succeed(ctx); }; - const stopSessionInternal = (ctx: CursorSessionContext) => + const stopSessionInternal = (ctx: AcpSessionContext) => Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; @@ -447,7 +495,7 @@ export function makeCursorAdapter( }); }); - const startSession: CursorAdapterShape["startSession"] = (input) => + const startSession: ProviderAdapterShape["startSession"] = (input) => withThreadLock( input.threadId, Effect.gen(function* () { @@ -467,7 +515,7 @@ export function makeCursorAdapter( } const cwd = path.resolve(input.cwd.trim()); - const cursorModelSelection = + const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { @@ -481,16 +529,16 @@ export function makeCursorAdapter( yield* Effect.addFinalizer(() => sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), ); - let ctx!: CursorSessionContext; + let ctx!: AcpSessionContext; - const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; + const resumeSessionId = parseAcpResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ nativeEventLogger, provider: PROVIDER, threadId: input.threadId, }); - // Resolve the CursorSettings used to spawn the ACP child. Production + // 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 @@ -498,39 +546,41 @@ export function makeCursorAdapter( // snapshot from `ServerSettingsService` so that mid-suite // `updateSettings({ providers: { cursor: { binaryPath } } })` calls // actually take effect when the next session spawns. - const effectiveCursorSettings = options?.resolveSettings + const effectiveSettings = options?.resolveSettings ? yield* options.resolveSettings - : cursorSettings; - - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); + : definition.settings; + + const acp = yield* definition + .makeRuntime({ + settings: effectiveSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + runtimeMode: input.runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + nativeLoggers: acpNativeLoggers, + }) + .pipe( + 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) => + yield* acp.handleElicitation((params) => Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); + 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(crypto.randomUUID()); const runtimeRequestId = RuntimeRequestId.make(requestId); const answers = yield* Deferred.make(); @@ -542,10 +592,10 @@ export function makeCursorAdapter( threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, + payload: { questions: form.questions }, raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", + source: "acp.jsonrpc", + method: "session/elicitation", payload: params, }, }); @@ -560,55 +610,100 @@ export function makeCursorAdapter( requestId: runtimeRequestId, payload: { answers: resolved }, }); - return { answers: resolved }; + return form.resolve(resolved); }), ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => - 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) => + if (definition.cursorExtensions) { + yield* acp.handleExtRequest( + "cursor/ask_question", + CursorAskQuestionRequest, + (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + 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) => Effect.gen(function* () { yield* logNative( input.threadId, - "cursor/update_todos", + "cursor/create_plan", params, "acp.cursor.extension", ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), + 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) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", params, "acp.cursor.extension", - "cursor/update_todos", ); - } - }), - ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } + }), + ); + } yield* acp.handleRequestPermission((params) => Effect.gen(function* () { yield* logNative( @@ -617,7 +712,18 @@ export function makeCursorAdapter( params, "acp.jsonrpc", ); - if (input.runtimeMode === "full-access") { + const permissionRequest = parsePermissionRequest(params); + const autoApprove = + input.runtimeMode === "full-access" || + (input.runtimeMode === "auto-accept-edits" && + (permissionRequest.kind === "edit" || + permissionRequest.kind === "delete" || + permissionRequest.kind === "move")) || + definition.shouldAutoApprovePermission?.({ + runtimeMode: input.runtimeMode, + permissionKind: permissionRequest.kind, + }) === true; + if (autoApprove) { const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); if (autoApprovedOptionId !== undefined) { return { @@ -628,7 +734,6 @@ export function makeCursorAdapter( }; } } - const permissionRequest = parsePermissionRequest(params); const requestId = ApprovalRequestId.make(crypto.randomUUID()); const runtimeRequestId = RuntimeRequestId.make(requestId); const decision = yield* Deferred.make(); @@ -667,15 +772,13 @@ export function makeCursorAdapter( decision: resolved, }), ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(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(); @@ -689,7 +792,14 @@ export function makeCursorAdapter( runtime: acp, runtimeMode: input.runtimeMode, interactionMode: undefined, - modelSelection: cursorModelSelection, + modelSelection: selectedModel, + applyModelSelection: ({ runtime, model, selections }) => + definition.applyModelSelection({ + runtime, + threadId: input.threadId, + model, + selections, + }), mapError: ({ cause, method }) => mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), }); @@ -701,10 +811,10 @@ export function makeCursorAdapter( status: "ready", runtimeMode: input.runtimeMode, cwd, - model: cursorModelSelection?.model, + model: selectedModel?.model, threadId: input.threadId, resumeCursor: { - schemaVersion: CURSOR_RESUME_VERSION, + schemaVersion: ACP_RESUME_VERSION, sessionId: started.sessionId, }, createdAt: now, @@ -828,7 +938,7 @@ export function makeCursorAdapter( ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { state: "ready", reason: "Cursor ACP session ready" }, + payload: { state: "ready", reason: `${definition.displayName} ACP session ready` }, }); yield* offerRuntimeEvent({ type: "thread.started", @@ -842,14 +952,14 @@ export function makeCursorAdapter( }).pipe(Effect.scoped), ); - const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); const turnId = TurnId.make(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = resolveCursorAcpBaseModelId(model); + const resolvedModel = definition.resolveModelId(model); yield* applyRequestedSessionConfiguration({ runtime: ctx.acp, runtimeMode: ctx.session.runtimeMode, @@ -861,6 +971,13 @@ export function makeCursorAdapter( 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), }); @@ -962,7 +1079,7 @@ export function makeCursorAdapter( }; }); - const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => + const interruptTurn: ProviderAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); @@ -976,7 +1093,7 @@ export function makeCursorAdapter( ); }); - const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( threadId, requestId, decision, @@ -994,7 +1111,7 @@ export function makeCursorAdapter( yield* Deferred.succeed(pending.decision, decision); }); - const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( threadId, requestId, answers, @@ -1005,20 +1122,23 @@ export function makeCursorAdapter( if (!pending) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, - method: "cursor/ask_question", + method: definition.cursorExtensions ? "cursor/ask_question" : "session/elicitation", detail: `Unknown pending user-input request: ${requestId}`, }); } yield* Deferred.succeed(pending.answers, answers); }); - const readThread: CursorAdapterShape["readThread"] = (threadId) => + const readThread: ProviderAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); return { threadId, turns: ctx.turns }; }); - const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { @@ -1033,7 +1153,7 @@ export function makeCursorAdapter( return { threadId, turns: ctx.turns }; }); - const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => withThreadLock( threadId, Effect.gen(function* () { @@ -1042,16 +1162,16 @@ export function makeCursorAdapter( }), ); - const listSessions: CursorAdapterShape["listSessions"] = () => + const listSessions: ProviderAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); - const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const c = sessions.get(threadId); return c !== undefined && !c.stopped; }); - const stopAll: CursorAdapterShape["stopAll"] = () => + const stopAll: ProviderAdapterShape["stopAll"] = () => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); yield* Effect.addFinalizer(() => @@ -1078,6 +1198,47 @@ export function makeCursorAdapter( hasSession, stopAll, streamEvents, - } satisfies CursorAdapterShape; + } satisfies ProviderAdapterShape; }); } + +export function makeCursorAdapter( + cursorSettings: CursorSettings, + options?: CursorAdapterLiveOptions, +) { + return makeAcpProviderAdapter({ + provider: PROVIDER, + defaultInstanceId: ProviderInstanceId.make("cursor"), + displayName: "Cursor", + settings: cursorSettings, + ...(options ? { options } : {}), + cursorExtensions: true, + makeRuntime: ({ + settings, + environment, + childProcessSpawner, + cwd, + resumeSessionId, + clientInfo, + nativeLoggers, + }) => + makeCursorAcpRuntime({ + cursorSettings: settings, + ...(environment ? { environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo, + ...nativeLoggers, + }), + applyModelSelection: ({ runtime, threadId, model, selections }) => + applyCursorAcpModelSelection({ + runtime, + model, + selections, + mapError: ({ cause }) => + mapAcpToAdapterError(PROVIDER, threadId, "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..cf5a5e223bb5 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -0,0 +1,198 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as os from "node:os"; +import * as path from "node:path"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } 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 = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.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 mkdtemp(path.join(os.tmpdir(), "omp-acp-mock-")); + const wrapperPath = path.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 writeFile(wrapperPath, script, "utf8"); + await 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(() => mkdtemp(path.join(os.tmpdir(), "omp-argv-"))); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => 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(() => readFile(argvLogPath, "utf8"))) + .trim() + .split("\t") + .filter(Boolean), + ["acp", "--profile", "work", "--approval-mode", "yolo"], + ); + + 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..35705a3d77ab --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -0,0 +1,58 @@ +/** + * 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 "./CursorAdapter.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, + ...(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 }) => + mapAcpToAdapterError(PROVIDER, threadId, "session/set_config_option", cause), + }), + 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..e7b77d7f11f6 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +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..8d9db0e3d092 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -0,0 +1,354 @@ +import { + ProviderDriverKind, + 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, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("omp"); +const OMP_PRESENTATION = { + displayName: "OMP", + showInteractionModeToggle: true, +} as const; +const OMP_VERSION_TIMEOUT_MS = 5_000; +const OMP_MODELS_TIMEOUT_MS = 20_000; +const MINIMUM_OMP_ACP_VERSION = "15.0.0"; + +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: process.platform === "win32", + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + 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( + [], + PROVIDER, + 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( + [], + PROVIDER, + 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 for ACP support. Run \`omp update\` to install v${MINIMUM_OMP_ACP_VERSION} or newer.`, + }); + } + + 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, + PROVIDER, + 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 86f99c97326f..c8dc17d3a4fa 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`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `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 @@ -29,6 +29,7 @@ import { type CodexSettings, type CursorSettings, type OpenCodeSettings, + type OmpSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -42,6 +43,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.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"; @@ -88,6 +90,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 @@ -218,7 +228,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }); describe("ProviderInstanceRegistryLive — all drivers slice", () => { - // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + + // All five drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via @@ -245,11 +255,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_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 openCodeDriverKind = ProviderDriverKind.make("opencode"); + const ompDriverKind = ProviderDriverKind.make("omp"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -279,10 +291,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, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver, OmpDriver], configMap, }); @@ -292,9 +310,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(4); + expect(instances).toHaveLength(5); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, openCodeId, ompId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -304,30 +322,46 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); 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(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(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 // distinct references even when two instances happen to share a // trait (e.g. Cursor + others all use a stub-or-real // `textGeneration`; they must still be different object values). - const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; + const adapters = [ + codex!.adapter, + claude!.adapter, + cursor!.adapter, + openCode!.adapter, + omp!.adapter, + ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ codex!.textGeneration, claude!.textGeneration, cursor!.textGeneration, openCode!.textGeneration, + omp!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; + const snapshots = [ + codex!.snapshot, + claude!.snapshot, + cursor!.snapshot, + openCode!.snapshot, + omp!.snapshot, + ]; expect(new Set(snapshots).size).toBe(snapshots.length); // Snapshots identify themselves by `instanceId` + `driver` so @@ -363,6 +397,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 fb6eb3b443d8..86b248782c41 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -993,6 +993,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T claudeAgent: { enabled: false }, cursor: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals @@ -1087,6 +1088,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T claudeAgent: { enabled: false }, cursor: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, }), ), @@ -1182,6 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T claudeAgent: { enabled: false }, cursor: { enabled: false }, opencode: { enabled: false }, + omp: { enabled: false }, }, providerInstances: { ghost_main: { @@ -1240,6 +1243,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T cursor: { enabled: false, }, + omp: { + enabled: false, + }, }, }), ), @@ -1300,6 +1306,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "claudeAgent", "codex", "cursor", + "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 a7fcdc4c827b..13466f35e518 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -6,9 +6,14 @@ 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("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..b1837f8b56e3 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,22 @@ 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 desiredKind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const byKind = options.find((option) => option.kind === desiredKind)?.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([desiredKind, desiredKind.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..6497567a2db4 --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +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("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 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"], + properties: { + strategy: { type: "string", enum: ["safe", "fast"] }, + count: { type: "integer" }, + }, + }, + }), + ).toBeUndefined(); + }); + + 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..a7c01af240d1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.ts @@ -0,0 +1,174 @@ +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 choicesForProperty(property: ElicitationProperty): ReadonlyArray { + if (property.type === "string") { + if (property.oneOf && property.oneOf.length > 0) { + return property.oneOf.map((option) => ({ + label: nonEmpty(option.title) ?? option.const, + value: option.const, + })); + } + return (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 property.items.anyOf.map((option) => ({ + label: nonEmpty(option.title) ?? option.const, + value: option.const, + })); + } + return 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; + // T3's structured user-input surface currently renders selectable options + // only. Emitting an optionless question causes the web client to discard it + // while the ACP handler waits forever for an answer. Cancel the whole form + // when any property needs unsupported free-form or numeric input. + if (propertyEntries.some(([, property]) => choicesForProperty(property).length === 0)) { + return undefined; + } + + const questions = propertyEntries.map(([id, property]) => + questionForProperty({ + request, + id, + property, + propertyCount: propertyEntries.length, + }), + ); + const required = new Set(request.requestedSchema.required ?? []); + + 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..9f4631d1c5c6 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -0,0 +1,102 @@ +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect, it } from "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", "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("sets the exact model before applying options advertised by that model", async () => { + 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 }); + }), + }; + + await Effect.runPromise( + 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..6f6c2ed7d042 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -0,0 +1,146 @@ +import { + type OmpSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; +import { findSessionConfigOption } from "./AcpRuntimeModel.ts"; + +type OmpAcpRuntimeSettings = Pick; + +export interface OmpAcpRuntimeInput extends Omit< + 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 "full-access": + return ["--approval-mode", "yolo"]; + case "auto-accept-edits": + // Override any profile-level `yolo` setting so OMP still surfaces + // execution requests while its own write operations remain automatic. + return ["--approval-mode", "write"]; + } +} + +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 => + 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).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 5af56dc6b0e8..cc17588ceb9c 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OmpDriver, type OmpDriverEnv } from "./Drivers/OmpDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -35,7 +36,8 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | OmpDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -47,4 +49,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* ServerSettingsService; + const provisionalModel = "gpt-5.4-mini"; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: false }, + claudeAgent: { 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* () { const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5ea2e03813f7..3238fcaa66f1 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,7 +11,6 @@ * @module ServerSettings */ import { - DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, isProviderDriverKind, @@ -206,9 +205,15 @@ 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_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_GIT_TEXT_GENERATION_MODEL, + settings.textGenerationModelSelection.model, } satisfies ModelSelection, }; } diff --git a/apps/server/src/textGeneration/AcpTextGeneration.ts b/apps/server/src/textGeneration/AcpTextGeneration.ts new file mode 100644 index 000000000000..b7ba487e7cae --- /dev/null +++ b/apps/server/src/textGeneration/AcpTextGeneration.ts @@ -0,0 +1,289 @@ +import { TextGenerationError, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +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 * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import type { AcpSessionRuntimeShape } from "../provider/acp/AcpSessionRuntime.ts"; +import type { ThreadTitleGenerationResult, TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +export type AcpTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +interface AcpTextGenerationRuntimeInput { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cwd: string; +} + +interface AcpTextGenerationConfigureInput { + readonly runtime: AcpSessionRuntimeShape; + readonly modelSelection: ModelSelection; + readonly operation: AcpTextGenerationOperation; +} + +export interface AcpTextGenerationOptions { + /** Name used in user-facing response errors, for example "Cursor Agent". */ + readonly agentName: string; + /** Name used in transport errors, for example "Cursor ACP". */ + readonly transportName: string; + /** Prefix used for Effect tracing names. */ + readonly effectNamePrefix: string; + readonly timeoutMs: number; + readonly makeRuntime: ( + input: AcpTextGenerationRuntimeInput, + ) => Effect.Effect; + /** Apply provider-specific mode, model, and option configuration after start. */ + readonly configureRuntime: ( + input: AcpTextGenerationConfigureInput, + ) => Effect.Effect; +} + +export function makeAcpTextGenerationError( + operation: AcpTextGenerationOperation, + detail: string, + cause?: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +/** + * Build structured text generation on top of a provider-specific ACP runtime. + * + * Providers only supply process/session construction and model selection. The + * streaming, timeout, JSON decoding, prompt construction, and sanitization + * behavior stays identical across ACP-backed providers. + */ +export const makeAcpTextGeneration = Effect.fn("makeAcpTextGeneration")(function* ( + options: AcpTextGenerationOptions, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runAcpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: AcpTextGenerationOperation; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* options.makeRuntime({ + childProcessSpawner: commandSpawner, + cwd, + }); + + 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* options.configureRuntime({ runtime, modelSelection, operation }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(options.timeoutMs), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + makeAcpTextGenerationError(operation, `${options.agentName} request timed out.`), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : makeAcpTextGenerationError( + operation, + `${options.transportName} request failed.`, + cause, + ), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* makeAcpTextGenerationError( + operation, + promptResult.stopReason === "cancelled" + ? `${options.transportName} request was cancelled.` + : `${options.agentName} returned empty output.`, + ); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + makeAcpTextGenerationError( + operation, + `${options.agentName} returned invalid structured output.`, + cause, + ), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : makeAcpTextGenerationError( + operation, + `${options.transportName} text generation failed.`, + cause, + ), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + `${options.effectNamePrefix}.generateCommitMessage`, + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runAcpJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + `${options.effectNamePrefix}.generatePrContent`, + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runAcpJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + `${options.effectNamePrefix}.generateBranchName`, + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + `${options.effectNamePrefix}.generateThreadTitle`, + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d10..98d9a027e391 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -1,58 +1,14 @@ +import type { CursorSettings } from "@t3tools/contracts"; 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 { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; - -import { TextGenerationError } from "@t3tools/contracts"; -import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "./TextGenerationUtils.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime, } from "../provider/acp/CursorAcpSupport.ts"; +import { makeAcpTextGeneration, makeAcpTextGenerationError } from "./AcpTextGeneration.ts"; const CURSOR_TIMEOUT_MS = 180_000; -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} - /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` * payload. See `makeCodexAdapter` for the overall per-instance rationale. @@ -61,56 +17,28 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu cursorSettings: CursorSettings, environment: NodeJS.ProcessEnv = process.env, ) { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const runCursorJson = ({ - 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* makeCursorAcpRuntime({ + return yield* makeAcpTextGeneration({ + agentName: "Cursor Agent", + transportName: "Cursor ACP", + effectNamePrefix: "CursorTextGeneration", + timeoutMs: CURSOR_TIMEOUT_MS, + makeRuntime: ({ childProcessSpawner, cwd }) => + makeCursorAcpRuntime({ cursorSettings, environment, - childProcessSpawner: commandSpawner, + childProcessSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); - - 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(); + }), + configureRuntime: ({ runtime, modelSelection, operation }) => + Effect.gen(function* () { yield* Effect.ignore(runtime.setMode("ask")); yield* applyCursorAcpModelSelection({ runtime, model: modelSelection.model, selections: modelSelection.options, mapError: ({ cause, configId, step }) => - mapCursorAcpError( + makeAcpTextGenerationError( operation, step === "set-config-option" ? `Failed to set Cursor ACP config option "${configId}" for text generation.` @@ -118,161 +46,6 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu cause, ), }); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(CURSOR_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent request timed out.", - }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), - ), - ); - - const rawResult = (yield* Ref.get(outputRef)).trim(); - if (!rawResult) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "Cursor ACP request was cancelled." - : "Cursor Agent returned empty output.", - }); - } - - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - ), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CursorTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runCursorJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CursorTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runCursorJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CursorTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CursorTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; + }), }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGenerationShape; }); diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts new file mode 100644 index 000000000000..0030dd062550 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -0,0 +1,129 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } 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 "vitest"; + +import { ServerConfig } from "../config.ts"; +import { makeOmpTextGeneration } from "./OmpTextGeneration.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); +const decodeOmpSettings = Schema.decodeSync(OmpSettings); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); +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 = path.join(dir, "bin"); + const ompPath = path.join(binDir, "omp"); + mkdirSync(binDir, { recursive: true }); + 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", + ); + 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 = mkdtempSync(path.join(os.tmpdir(), "t3code-omp-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ); + + const requestLogPath = path.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 = 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..18d4170c03e5 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -0,0 +1,42 @@ +import type { OmpSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../provider/acp/OmpAcpSupport.ts"; +import { makeAcpTextGeneration, makeAcpTextGenerationError } from "./AcpTextGeneration.ts"; + +const OMP_TIMEOUT_MS = 180_000; + +/** Build an OMP ACP text-generation closure bound to one provider instance. */ +export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function* ( + ompSettings: OmpSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + return yield* makeAcpTextGeneration({ + agentName: "OMP", + transportName: "OMP ACP", + effectNamePrefix: "OmpTextGeneration", + timeoutMs: OMP_TIMEOUT_MS, + makeRuntime: ({ childProcessSpawner, cwd }) => + makeOmpAcpRuntime({ + ompSettings, + environment, + childProcessSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }), + configureRuntime: ({ runtime, modelSelection, operation }) => + applyOmpAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + makeAcpTextGenerationError( + operation, + 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, + ), + }), + }); +}); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 2f518f1656d1..a218d0ba8ab2 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 "vitest"; -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"; @@ -26,21 +31,44 @@ const makeStubTextGeneration = (overrides: Partial): TextGe const makeStubInstance = ( instanceId: ProviderInstanceId, textGeneration: TextGenerationShape, + 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, ): ProviderInstanceRegistryShape => { @@ -117,4 +145,76 @@ 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[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 = 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 = 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"); + } + }), + ); }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 36a23d509db3..9bbda8946c19 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,8 +1,13 @@ 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 { ProviderInstanceRegistry, @@ -10,7 +15,10 @@ import { } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "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: ProviderInstanceRegistryShape, 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,24 +151,104 @@ 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; + } + + // The first snapshot can still be pending. Refresh only when it has no + // inventory; once models are known, reuse that managed snapshot rather + // than running `omp models --json` for every provisional fallback. + const resolvedSnapshot = + current.models.length === 0 ? yield* instance.snapshot.refresh : current; + 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: ProviderInstanceRegistryShape, + 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: ProviderInstanceRegistryShape, ): TextGenerationShape => ({ 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 b3211e17753a..c9d78e4b80ef 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -651,6 +651,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 }) => ( > = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [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 3a36e2a51e50..1755c8480fc5 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -296,7 +296,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 5db713495b92..7834e76d0cea 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 8d3d7482f62a..c55115787a5c 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + OmpSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, type Icon, OmpIcon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -59,6 +60,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 67f754bd7155..1a2661314a0c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -23,9 +23,11 @@ import { createModelSelection } from "@t3tools/shared/model"; const CODEX_INSTANCE = ProviderInstanceId.make("codex"); 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>; @@ -132,6 +134,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, @@ -444,38 +458,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([ { @@ -490,30 +493,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({}); @@ -521,6 +513,34 @@ describe("composerDraftStore terminal contexts", () => { }); }); +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"); const otherProjectId = ProjectId.make("project-b"); @@ -1185,6 +1205,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 d554ad7b0d32..10ee4966ef9c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -640,6 +640,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. * @@ -652,12 +677,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; } } @@ -813,10 +836,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, @@ -2414,10 +2435,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 7104f365ebff..11ca8d18e0f0 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -42,6 +42,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 a7767672fa16..feb4593baf54 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("omp"), + label: "OMP", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79b..64d6b3b22621 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); export const DEFAULT_MODEL = "gpt-5.4"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; @@ -200,4 +201,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [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 39695fe3b01e..bd01c6f7fd4a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -19,6 +19,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", () => { @@ -107,6 +113,10 @@ describe("ServerSettingsPatch string normalization", () => { binaryPath: " /opt/homebrew/bin/codex ", homePath: " ~/.codex ", }, + omp: { + binaryPath: " /opt/homebrew/bin/omp ", + profile: " work ", + }, }, providerInstances: { codex_personal: { @@ -122,6 +132,8 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); + 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 2d115eed98e1..e5b0b99e8982 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,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(""))), @@ -370,6 +405,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.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 @@ -445,6 +481,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 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -464,6 +507,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + omp: Schema.optionalKey(OmpSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual