From 17532ead914a25157ed9218b7a27f15aa59385a7 Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Fri, 21 Aug 2026 01:27:30 -0600 Subject: [PATCH] feat(providers): add Oh My Pi support --- README.md | 5 +- apps/mobile/THIRD_PARTY_NOTICES.md | 15 + apps/mobile/src/components/ProviderIcon.tsx | 18 +- .../features/threads/ThreadSettingsSheet.tsx | 19 +- .../threads/new-task-flow-provider.tsx | 13 +- apps/mobile/src/lib/modelOptions.test.ts | 62 ++ apps/mobile/src/lib/modelOptions.ts | 24 +- apps/server/scripts/acp-mock-agent.ts | 14 +- apps/server/src/provider/Drivers/OmpDriver.ts | 171 +++ .../src/provider/Layers/CursorAdapter.ts | 2 + .../server/src/provider/Layers/GrokAdapter.ts | 3 + .../src/provider/Layers/OmpAdapter.test.ts | 320 ++++++ apps/server/src/provider/Layers/OmpAdapter.ts | 970 ++++++++++++++++++ .../src/provider/Layers/OmpProvider.test.ts | 253 +++++ .../server/src/provider/Layers/OmpProvider.ts | 517 ++++++++++ .../ProviderInstanceRegistryLive.test.ts | 46 +- .../src/provider/Services/OmpAdapter.ts | 11 + .../src/provider/acp/AcpRuntimeModel.test.ts | 118 +++ .../src/provider/acp/AcpRuntimeModel.ts | 58 ++ .../src/provider/acp/AcpSessionRuntime.ts | 20 +- .../src/provider/acp/OmpAcpSupport.test.ts | 203 ++++ apps/server/src/provider/acp/OmpAcpSupport.ts | 603 +++++++++++ apps/server/src/provider/builtInDrivers.ts | 5 +- .../textGeneration/OmpTextGeneration.test.ts | 201 ++++ .../src/textGeneration/OmpTextGeneration.ts | 286 ++++++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/THIRD_PARTY_NOTICES.md | 14 + apps/web/src/components/Icons.tsx | 41 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/ProviderSettingsForm.test.ts | 9 + .../components/settings/providerDriverMeta.ts | 9 +- apps/web/src/composerDraftStore.ts | 6 +- apps/web/src/lib/contextWindow.test.ts | 10 +- apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/session-logic.ts | 6 + docs/README.md | 2 +- docs/internals/glossary.md | 2 +- docs/internals/overview.md | 8 +- docs/internals/providers.md | 9 +- docs/user/install.md | 5 +- docs/user/providers-omp.md | 57 + packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.test.ts | 27 + packages/contracts/src/settings.ts | 46 + 45 files changed, 4169 insertions(+), 59 deletions(-) create mode 100644 apps/mobile/THIRD_PARTY_NOTICES.md create mode 100644 apps/server/src/provider/Drivers/OmpDriver.ts create mode 100644 apps/server/src/provider/Layers/OmpAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/OmpAdapter.ts create mode 100644 apps/server/src/provider/Layers/OmpProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OmpProvider.ts create mode 100644 apps/server/src/provider/Services/OmpAdapter.ts create mode 100644 apps/server/src/provider/acp/OmpAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/OmpAcpSupport.ts create mode 100644 apps/server/src/textGeneration/OmpTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/OmpTextGeneration.ts create mode 100644 docs/user/providers-omp.md diff --git a/README.md b/README.md index 8ec101387f67..ae2998acd80b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Oh My Pi. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Oh My Pi. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - Oh My Pi: install [Oh My Pi](https://omp.sh) and run `omp setup` ### Try it out (install-free) diff --git a/apps/mobile/THIRD_PARTY_NOTICES.md b/apps/mobile/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000000..7e769f9ea3dd --- /dev/null +++ b/apps/mobile/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Third-Party Notices + +## Oh My Pi + +The Oh My Pi provider icon in `src/components/ProviderIcon.tsx` is adapted +from the [`oh-my-pi`](https://github.com/can1357/oh-my-pi) project. + +Copyright (c) 2025 Mario Zechner + +Copyright (c) 2025-2026 Can Bölük + +Copyright (c) 2026 Stencil Labs, Inc. + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..f23a064aabb2 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,4 +1,4 @@ -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Rect, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { @@ -59,6 +59,22 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "omp") { + const fill = isDarkMode ? "#FAFAFA" : "#211E1E"; + return ( + + + + + + + + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index b47e41d3e01e..cc39e3c6c613 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -94,7 +94,11 @@ function ModelRow(props: { const checkmarkColor = useThemeColor("--color-icon"); return ( - - {props.option.label} - + + + {props.option.label} + + {props.option.subProvider ? ( + + {props.option.subProvider} + + ) : null} + {props.option.isDefault ? ( Default diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..2df87c41267a 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -34,6 +34,7 @@ import { groupByProvider, resolveDefaultableModelSelection, resolveSelectableModelSelection, + selectedProviderShowsInteractionModeToggle, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -194,7 +195,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const { enabled: planModePreferenceEnabled, loaded: planModePreferenceLoaded } = + useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -401,9 +403,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently @@ -433,6 +432,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; + const planModeEnabled = + planModePreferenceEnabled && + selectedProviderShowsInteractionModeToggle(selectedEnvironmentServerConfig, selectedModel); + const interactionMode = planModeEnabled + ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + : DEFAULT_PROVIDER_INTERACTION_MODE; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..75a92e6245d7 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -7,9 +7,43 @@ import { groupByProvider, resolveDefaultableModelSelection, resolveSelectableModelSelection, + selectedProviderShowsInteractionModeToggle, } from "./modelOptions"; describe("mobile model options", () => { + it("preserves the upstream provider for OMP model rows", () => { + const config = { + providers: [ + { + instanceId: "omp", + driver: "omp", + displayName: "Oh My Pi", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "moonshot/kimi-k2.6", + name: "Kimi K2.6", + subProvider: "Moonshot", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect(buildModelOptions(config, null)).toMatchObject([ + { + label: "Kimi K2.6", + providerLabel: "Oh My Pi", + subProvider: "Moonshot", + subtitle: "Oh My Pi · Moonshot", + }, + ]); + }); + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ @@ -171,4 +205,32 @@ describe("mobile model options", () => { // Offline: nothing to validate against, selection passes through. expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); }); + + it("uses the selected provider interaction-mode capability", () => { + const config = { + providers: [ + { + instanceId: "omp", + driver: "omp", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + showInteractionModeToggle: false, + models: [], + }, + ], + } as unknown as ServerConfig; + const ompSelection = { + instanceId: ProviderInstanceId.make("omp"), + model: "moonshot/kimi-k2.6", + }; + const missingSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }; + + expect(selectedProviderShowsInteractionModeToggle(config, ompSelection)).toBe(false); + expect(selectedProviderShowsInteractionModeToggle(config, missingSelection)).toBe(true); + expect(selectedProviderShowsInteractionModeToggle(null, ompSelection)).toBe(true); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..c11d7c85bbe1 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -14,6 +14,7 @@ export type ModelOption = { readonly subtitle: string; readonly providerKey: string; readonly providerLabel: string; + readonly subProvider?: string; readonly providerDriver: string; readonly isDefault: boolean; readonly isLegacy: boolean; @@ -35,6 +36,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "omp") return "Oh My Pi"; return provider.instanceId; } @@ -104,6 +106,24 @@ export function resolveDefaultableModelSelection( return model?.isLegacy === true ? null : usable; } +/** + * The server owns whether a provider supports interaction-mode selection. If + * the environment is offline or the selected provider is not in its latest + * snapshot, preserve the existing control until the server can describe it. + */ +export function selectedProviderShowsInteractionModeToggle( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, +): boolean { + if (!config || !selection) { + return true; + } + return ( + config.providers.find((provider) => provider.instanceId === selection.instanceId) + ?.showInteractionModeToggle ?? true + ); +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -118,12 +138,14 @@ export function buildModelOptions( const providerLabel = providerDisplayLabel(provider); for (const model of provider.models) { const key = `${provider.instanceId}:${model.slug}`; + const subProvider = model.subProvider?.trim(); options.set(key, { key, label: model.name, - subtitle: providerLabel, + subtitle: subProvider ? `${providerLabel} · ${subProvider}` : providerLabel, providerKey: provider.instanceId, providerLabel, + ...(subProvider ? { subProvider } : {}), providerDriver: provider.driver, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..a5081e09283f 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -14,6 +14,7 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitEditPermission = process.env.T3_ACP_EMIT_EDIT_PERMISSION === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; @@ -660,15 +661,18 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, toolCall: { toolCallId, - title: "`cat server/package.json`", - kind: "execute", + title: emitEditPermission ? "Delete obsolete file" : "`cat server/package.json`", + ...(emitEditPermission ? {} : { kind: "execute" as const }), status: "pending", + ...(emitEditPermission ? { locations: [{ path: "/tmp/obsolete.ts" }] } : {}), content: [ { type: "content", content: { type: "text", - text: "Not in allowlist: cat server/package.json", + text: emitEditPermission + ? "Delete obsolete file" + : "Not in allowlist: cat server/package.json", }, }, ], @@ -693,8 +697,8 @@ const program = Effect.gen(function* () { update: { sessionUpdate: "tool_call_update", toolCallId, - title: "Terminal", - kind: "execute", + title: emitEditPermission ? "Delete obsolete file" : "Terminal", + ...(emitEditPermission ? {} : { kind: "execute" as const }), status: "completed", rawOutput: { exitCode: 0, diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts new file mode 100644 index 000000000000..3a75aa9ba630 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -0,0 +1,171 @@ +/** ProviderDriver for the Oh My Pi ACP runtime (`omp acp`). */ +import { OmpSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOmpTextGeneration } from "../../textGeneration/OmpTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOmpAdapter } from "../Layers/OmpAdapter.ts"; +import { + buildInitialOmpProviderSnapshot, + checkOmpProviderStatus, + enrichOmpSnapshot, +} 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 { + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeOmpSettings = Schema.decodeSync(OmpSettings); +const DRIVER_KIND = ProviderDriverKind.make("omp"); + +function isOmpNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return normalized.endsWith("/omp") || normalized.endsWith("/omp.exe"); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@oh-my-pi/pi-coding-agent", + homebrewFormula: "can1357/tap/omp", + nativeUpdate: { + executable: "omp", + args: ["update"], + lockKey: "omp-native", + isCommandPath: isOmpNativeCommandPath, + }, +}); + +export type OmpDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OmpDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Oh My Pi", + 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 serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OmpSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeOmpAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv); + const checkProvider = checkOmpProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialOmpProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichOmpSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Oh My Pi 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 30c173d8fae8..3ae7788c376b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -790,6 +790,8 @@ export function makeCursorAdapter( yield* Deferred.succeed(event.acknowledge, undefined); return; case "ModeChanged": + case "UsageUpdated": + case "AvailableCommandsUpdated": return; case "AssistantItemStarted": yield* offerRuntimeEvent( diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..c3ef8ac19755 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -869,6 +869,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); return; + case "UsageUpdated": + case "AvailableCommandsUpdated": + return; } }), ), 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..d4be2222a9cc --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -0,0 +1,320 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +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 { OmpSettings, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { OmpAdapterShape } from "../Services/OmpAdapter.ts"; +import { + buildOmpElicitationContent, + ompElicitationQuestions, + selectOmpPermissionOptionId, +} from "../acp/OmpAcpSupport.ts"; +import { makeOmpAdapter } from "./OmpAdapter.ts"; + +const decodeOmpSettings = Schema.decodeSync(OmpSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +class OmpAdapter extends Context.Service()( + "t3/provider/Layers/OmpAdapter.test/OmpAdapter", +) {} + +async function makeMockAgentWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-omp.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec node ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +const ompAdapterTestLayer = it.layer( + Layer.effect( + OmpAdapter, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const resolveSettings = serverSettings.getSettings.pipe( + Effect.map((snapshot) => snapshot.providers.omp), + Effect.orDie, + ); + return yield* makeOmpAdapter(decodeOmpSettings({}), { resolveSettings }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-omp-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), +); + +describe("OMP elicitation mapping", () => { + it("maps OMP choice forms and custom answers", () => { + const request = { + mode: "form", + sessionId: "session-1", + message: "Choose a deployment target", + requestedSchema: { + type: "object", + properties: { + q0: { + type: "string", + title: "Where should this deploy?", + oneOf: [ + { const: "Preview", title: "Preview" }, + { const: "Production", title: "Production" }, + ], + }, + q0__other: { type: "string", title: "Other" }, + confirmed: { type: "boolean", title: "Continue?" }, + }, + }, + } as const; + + const questions = ompElicitationQuestions(request); + assert.deepStrictEqual( + questions.map((entry) => entry.question.id), + ["q0", "confirmed"], + ); + assert.deepStrictEqual( + buildOmpElicitationContent(questions, { q0: "Staging", confirmed: "Yes" }), + { + q0__other: "Staging", + confirmed: true, + }, + ); + }); +}); + +describe("OMP permission mapping", () => { + it("returns the option ID that OMP supplied for each decision", () => { + const request = { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Edit file", + }, + options: [ + { optionId: "omp_allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "omp_allow_always", name: "Always allow", kind: "allow_always" }, + { optionId: "omp_reject_once", name: "Reject", kind: "reject_once" }, + ], + } as const; + + assert.equal(selectOmpPermissionOptionId(request, "accept"), "omp_allow_once"); + assert.equal(selectOmpPermissionOptionId(request, "acceptForSession"), "omp_allow_always"); + assert.equal(selectOmpPermissionOptionId(request, "decline"), "omp_reject_once"); + }); +}); + +ompAdapterTestLayer("OmpAdapterLive", (it) => { + it.effect("starts an OMP ACP session and maps a prompt to runtime events", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath, enabled: true } }, + }); + const threadId = ThreadId.make("omp-mock-thread"); + const eventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "composer-2", + }, + }); + assert.equal(session.provider, "omp"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const eventTypes = events.map((event) => event.type); + for (const eventType of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "turn.plan.updated", + "item.started", + "content.delta", + "item.completed", + "turn.completed", + ] as const) { + assert.include(eventTypes, eventType); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects an OMP session when the provider does not match", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const result = yield* adapter + .startSession({ + threadId: ThreadId.make("omp-provider-mismatch"), + provider: ProviderDriverKind.make("codex"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("loads an OMP ACP session from its resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath, enabled: true } }, + }); + const threadId = ThreadId.make("omp-resume-thread"); + const started = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.stopSession(threadId); + + const resumed = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: started.resumeCursor, + }); + + assert.deepStrictEqual(resumed.resumeCursor, started.resumeCursor); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("queues concurrent prompts as distinct turns", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "25" }), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath, enabled: true } }, + }); + const threadId = ThreadId.make("omp-concurrent-prompts"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const [first, second] = yield* Effect.all( + [ + adapter.sendTurn({ threadId, input: "first", attachments: [] }), + adapter.sendTurn({ threadId, input: "second", attachments: [] }), + ], + { concurrency: 2 }, + ); + + assert.notEqual(first.turnId, second.turnId); + const thread = yield* adapter.readThread(threadId); + assert.equal(thread.turns.length, 2); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("auto-approves OMP edit gates in auto-accept-edits mode", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_EMIT_EDIT_PERMISSION: "1", + }), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath, enabled: true } }, + }); + const threadId = ThreadId.make("omp-auto-accept-edit"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "auto-accept-edits", + }); + + const turn = yield* adapter.sendTurn({ threadId, input: "delete it", attachments: [] }); + + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects rollback because OMP cannot restore provider history", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath, enabled: true } }, + }); + const threadId = ThreadId.make("omp-rollback-unsupported"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const result = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.include(String(result.failure), "do not support provider-side rollback"); + } + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts new file mode 100644 index 000000000000..21deec796119 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -0,0 +1,970 @@ +/** + * OmpAdapterLive - OMP CLI (`omp acp`) via ACP. + * + * @module OmpAdapterLive + */ + +import { + ApprovalRequestId, + type OmpSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + OMP_RESUME_VERSION, + applyOmpRequestedSessionConfiguration, + buildOmpElicitationContent, + makeOmpAcpRuntime, + ompElicitationQuestions, + parseOmpResume, + selectAutoApprovedOmpPermissionOption, + selectOmpPermissionOptionId, + shouldAutoApproveOmpPermission, +} from "../acp/OmpAcpSupport.ts"; +import { type OmpAdapterShape } from "../Services/OmpAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("omp"); + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface OmpAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`omp`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `ompSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight, such as to + * swap `binaryPath` to a mock ACP wrapper, pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface OmpSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("omp"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate OMP runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process an OMP ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: OmpSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: OmpSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: OmpAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const ompModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: OmpSessionContext; + + const resumeSessionId = parseOmpResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the OMP settings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction. Per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { omp: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveOmpSettings = options?.resolveSettings + ? yield* options.resolveSettings + : ompSettings; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeOmpAcpRuntime({ + ompSettings: effectiveOmpSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + runtimeMode: input.runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleElicitation((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/elicitation", params, "acp.jsonrpc"); + if (params.mode !== "form") { + return { action: { action: "cancel" as const } }; + } + const questions = ompElicitationQuestions(params); + if (questions.length === 0) { + return { action: { action: "cancel" as const } }; + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: questions.map((entry) => entry.question) }, + raw: { + source: "acp.jsonrpc", + method: "session/elicitation", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + if (Object.keys(resolved).length === 0) { + return { action: { action: "cancel" as const } }; + } + return { + action: { + action: "accept" as const, + content: buildOmpElicitationContent(questions, resolved), + }, + }; + }), + ), + ); + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + const permissionRequest = parsePermissionRequest(params); + if (shouldAutoApproveOmpPermission(input.runtimeMode, permissionRequest)) { + const autoApprovedOptionId = selectAutoApprovedOmpPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + if (resolved === "cancel") { + return { outcome: { outcome: "cancelled" as const } }; + } + const optionId = selectOmpPermissionOptionId(params, resolved); + return { + outcome: optionId + ? { outcome: "selected" as const, optionId } + : { outcome: "cancelled" as const }, + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyOmpRequestedSessionConfiguration({ + runtime: acp, + modelSelection: ompModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: ompModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: OMP_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AvailableCommandsUpdated": + return; + case "UsageUpdated": + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + usage: { + usedTokens: event.payload.used, + ...(event.payload.size > 0 ? { maxTokens: event.payload.size } : {}), + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process an OMP runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "OMP ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: OmpAdapterShape["sendTurn"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(yield* randomUUIDv4); + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + yield* applyOmpRequestedSessionConfiguration({ + runtime: ctx.acp, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + + const interruptTurn: OmpAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: OmpAdapterShape["respondToRequest"] = (threadId, requestId, decision) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: OmpAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: OmpAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: OmpAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Oh My Pi ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: OmpAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: OmpAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: OmpAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: OmpAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit an OMP session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies 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..e67785424bfb --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -0,0 +1,253 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +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 { OmpSettings } from "@t3tools/contracts"; + +import { + buildOmpModelsFromJson, + checkOmpProviderStatus, + ompModelsCommandArgs, + parseOmpModelsJson, +} from "./OmpProvider.ts"; + +const decodeOmpSettings = Schema.decodeSync(OmpSettings); + +describe("OMP model catalog", () => { + it("builds per-model capabilities and provider labels from model JSON", () => { + expect( + buildOmpModelsFromJson( + { + models: [ + { + provider: "moonshot", + id: "kimi-k2.6", + selector: "moonshot/kimi-k2.6", + name: "Kimi K2.6", + thinking: ["low", "high"], + input: ["text", "image"], + }, + { + provider: "openrouter", + id: "moonshotai/kimi-k2.6", + selector: "openrouter/moonshotai/kimi-k2.6", + name: "Kimi K2.6", + thinking: null, + input: ["text"], + }, + { provider: "openai", selector: "openai/missing-name" }, + { + provider: "moonshot", + selector: "moonshot/kimi-k2.6", + name: "Duplicate", + thinking: [], + }, + ], + }, + { defaultModelRole: "moonshot/kimi-k2.6:high", defaultThinking: "low" }, + ), + ).toEqual([ + { + slug: "moonshot/kimi-k2.6", + name: "Kimi K2.6", + subProvider: "Moonshot", + isCustom: false, + isDefault: true, + capabilities: { + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + }, + }, + { + slug: "openrouter/moonshotai/kimi-k2.6", + name: "Kimi K2.6", + subProvider: "OpenRouter", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ]); + }); + + it("rejects malformed JSON and only preserves configured model catalog arguments", () => { + expect(parseOmpModelsJson("not json")).toBeUndefined(); + expect(parseOmpModelsJson('{"unexpected":[]}')).toEqual([]); + expect( + ompModelsCommandArgs('--config first.yml --extension unsafe.ts --config="second config.yml"'), + ).toEqual([ + "models", + "--json", + "--no-extensions", + "--config", + "first.yml", + "--config", + "second config.yml", + ]); + }); +}); + +it.layer(NodeServices.layer)("checkOmpProviderStatus", (it) => { + const makeOmpScript = (lines: ReadonlyArray) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-omp-status-" }); + const binaryPath = path.join(dir, "omp"); + yield* fs.writeFileString(binaryPath, ["#!/bin/sh", ...lines, ""].join("\n")); + yield* fs.chmod(binaryPath, 0o755); + return binaryPath; + }); + + it.effect("reports disabled settings without starting a process", () => + Effect.gen(function* () { + const snapshot = yield* checkOmpProviderStatus(decodeOmpSettings({ enabled: false })); + expect(snapshot).toMatchObject({ + enabled: false, + installed: false, + status: "disabled", + }); + }), + ); + + it.effect("reports a missing configured binary", () => + Effect.gen(function* () { + const snapshot = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: "/definitely/not/installed/omp" }), + ); + expect(snapshot).toMatchObject({ installed: false, status: "error" }); + expect(snapshot.message).toContain("not installed"); + }), + ); + + it.effect("rejects an invalid or old version before model discovery", () => + Effect.scoped( + Effect.gen(function* () { + const invalidBinaryPath = yield* makeOmpScript(['printf "not a version\\n"']); + const invalid = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: invalidBinaryPath }), + ); + expect(invalid.message).toContain("valid version"); + + const oldBinaryPath = yield* makeOmpScript([ + 'if [ "$1" = "--version" ]; then printf "omp/17.3.9\\n"; fi', + ]); + const old = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: oldBinaryPath }), + ); + expect(old.message).toContain("too old"); + }), + ), + ); + + it.effect("reports a failed or empty catalog", () => + Effect.scoped( + Effect.gen(function* () { + const failedBinaryPath = yield* makeOmpScript([ + 'if [ "$1" = "--version" ]; then printf "omp/17.4.0\\n"; exit 0; fi', + 'printf "catalog failed\\n" >&2', + "exit 2", + ]); + const failed = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: failedBinaryPath }), + ); + expect(failed.message).toContain("Failed to list Oh My Pi models"); + + const invalidCatalogBinaryPath = yield* makeOmpScript([ + 'if [ "$1" = "--version" ]; then printf "omp/17.4.0\\n"; exit 0; fi', + 'printf "not json\\n"', + ]); + const invalidCatalog = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: invalidCatalogBinaryPath }), + ); + expect(invalidCatalog.message).toContain("invalid model data"); + + const emptyBinaryPath = yield* makeOmpScript([ + 'if [ "$1" = "--version" ]; then printf "omp/17.4.0\\n"; exit 0; fi', + "printf '{\"models\":[]}\\n'", + ]); + const empty = yield* checkOmpProviderStatus( + decodeOmpSettings({ enabled: true, binaryPath: emptyBinaryPath }), + ); + expect(empty.message).toContain("returned no models"); + }), + ), + ); + + it.effect( + "preserves config overlays for model discovery without reading other config defaults", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const callsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-omp-calls-" }); + const callsPath = path.join(callsDir, "calls.log"); + const binaryPath = yield* makeOmpScript([ + 'printf "%s\\n" "$*" >> "$OMP_CALLS"', + 'if [ "$1" = "--version" ]; then printf "omp/17.4.0\\n"; exit 0; fi', + 'if [ "$1" = "models" ] && [ "$2" = "--json" ] && [ "$3" = "--no-extensions" ] && [ "$OMP_PROFILE" = "work profile" ]; then', + ' printf \'%s\\n\' \'{"models":[{"provider":"openai","id":"gpt-5.4","selector":"openai/gpt-5.4","name":"GPT-5.4","thinking":["low","high"],"input":["text","image"]}]}\'', + " exit 0", + "fi", + "exit 2", + ]); + const snapshot = yield* checkOmpProviderStatus( + decodeOmpSettings({ + enabled: true, + binaryPath, + launchArgs: + '--extension unsafe.ts --config selected.yml --config="fallback config.yml" --profile "work profile"', + customModels: ["extension/acme-model"], + }), + { ...process.env, OMP_CALLS: callsPath }, + ); + const calls = (yield* fs.readFileString(callsPath)).trim().split("\n").sort(); + expect(calls).toEqual( + [ + "--version", + "models --json --no-extensions --config selected.yml --config fallback config.yml", + ].sort(), + ); + expect(snapshot).toMatchObject({ installed: true, status: "ready", slashCommands: [] }); + expect(snapshot.models).toMatchObject([ + { + slug: "default", + name: "OMP config default", + isDefault: true, + }, + { + slug: "openai/gpt-5.4", + subProvider: "OpenAI", + capabilities: { + optionDescriptors: [ + { + id: "thinking", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + }, + { + slug: "extension/acme-model", + name: "extension/acme-model", + isCustom: true, + }, + ]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts new file mode 100644 index 000000000000..390e3f551e78 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -0,0 +1,517 @@ +import { + type ModelCapabilities, + type OmpSettings, + type ServerProviderModel, + type ServerProvider, +} from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { enrichProviderSnapshotWithVersionAdvisory } from "../providerMaintenance.ts"; +import { ompProfileFromLaunchArgs } from "../acp/OmpAcpSupport.ts"; + +export const OMP_PRESENTATION = { + displayName: "Oh My Pi", + showInteractionModeToggle: false, +} as const; + +export const OMP_MINIMUM_VERSION = "17.4.0"; +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const OMP_PROVIDER_WORD_LABELS: Readonly> = { + ai: "AI", + api: "API", + github: "GitHub", + gitlab: "GitLab", + lm: "LM", + openai: "OpenAI", + opencode: "OpenCode", + openrouter: "OpenRouter", + xai: "xAI", + zai: "Z.AI", +}; + +function ompSubProviderLabel(provider: string): string | undefined { + const normalized = provider.trim(); + if (!normalized) return undefined; + return normalized + .split(/[-_]+/) + .filter((word) => word.length > 0) + .map( + (word) => + OMP_PROVIDER_WORD_LABELS[word.toLowerCase()] ?? + `${word.charAt(0).toUpperCase()}${word.slice(1)}`, + ) + .join(" "); +} + +function asRecord(value: unknown): Readonly> | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : undefined; +} + +function trimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function stringArray(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap((entry) => { + const normalized = trimmedString(entry); + if (!normalized || seen.has(normalized)) return []; + seen.add(normalized); + return [normalized]; + }); +} + +function thinkingLabel(value: string): string { + return value + .split(/[-_\s]+/) + .filter((segment) => segment.length > 0) + .map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`) + .join(" "); +} + +function ompModelCapabilities( + thinking: ReadonlyArray, + defaultThinking: string | undefined, +): ModelCapabilities { + if (thinking.length === 0) return EMPTY_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "thinking", + label: "Thinking", + options: thinking.map((value) => ({ + value, + label: thinkingLabel(value), + ...(value === defaultThinking ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +export interface OmpModelCatalogDefaults { + readonly defaultModelRole?: string | undefined; + readonly defaultThinking?: string | undefined; +} + +/** Parses the documented `omp models --json` catalog without trusting malformed entries. */ +export function buildOmpModelsFromJson( + value: unknown, + defaults: OmpModelCatalogDefaults = {}, +): ReadonlyArray { + const catalog = asRecord(value); + if (!catalog || !Array.isArray(catalog.models)) return []; + + const catalogSelectors = catalog.models.flatMap((entry) => { + const selector = trimmedString(asRecord(entry)?.selector); + return selector ? [selector] : []; + }); + const defaultSelector = catalogSelectors + .toSorted((left, right) => right.length - left.length) + .find( + (selector) => + defaults.defaultModelRole === selector || + defaults.defaultModelRole?.startsWith(`${selector}:`), + ); + const roleThinking = + defaultSelector && defaults.defaultModelRole?.startsWith(`${defaultSelector}:`) + ? trimmedString(defaults.defaultModelRole.slice(defaultSelector.length + 1)) + : undefined; + + const seen = new Set(); + return catalog.models.flatMap((entry) => { + const model = asRecord(entry); + const selector = trimmedString(model?.selector); + const name = trimmedString(model?.name); + const provider = trimmedString(model?.provider); + if (!selector || !name || !provider || seen.has(selector)) return []; + seen.add(selector); + + return [ + { + slug: selector, + name, + subProvider: ompSubProviderLabel(provider), + isCustom: false, + ...(selector === defaultSelector ? { isDefault: true } : {}), + capabilities: ompModelCapabilities( + stringArray(model?.thinking), + selector === defaultSelector + ? (roleThinking ?? defaults.defaultThinking) + : defaults.defaultThinking, + ), + } satisfies ServerProviderModel, + ]; + }); +} + +export function parseOmpModelsJson( + output: string, + defaults?: OmpModelCatalogDefaults, +): ReadonlyArray | undefined { + try { + return buildOmpModelsFromJson(JSON.parse(output) as unknown, defaults); + } catch { + return undefined; + } +} + +function parseOmpConfigValue(output: string): unknown { + try { + return asRecord(JSON.parse(output) as unknown)?.value; + } catch { + return undefined; + } +} + +function ompConfigOverlayArgs(launchArgs: string | null | undefined): ReadonlyArray { + const configArgs: Array = []; + const args = tokenizeCliArgs(launchArgs ?? undefined); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) continue; + if (arg === "--config") { + const value = args[index + 1]?.trim(); + if (value) { + configArgs.push("--config", value); + index += 1; + } + continue; + } + if (arg.startsWith("--config=")) { + const value = arg.slice("--config=".length).trim(); + if (value) configArgs.push("--config", value); + } + } + return configArgs; +} + +export function ompModelsCommandArgs(launchArgs: string | null | undefined): ReadonlyArray { + const configArgs = ompConfigOverlayArgs(launchArgs); + return ["models", "--json", "--no-extensions", ...configArgs]; +} + +export function buildInitialOmpProviderSnapshot( + settings: OmpSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: [], + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Oh My Pi availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Oh My Pi is disabled in T3 Code settings.", + }, + }); + }); +} + +function runOmpCommand( + settings: Pick, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) { + return Effect.gen(function* () { + const command = settings.binaryPath?.trim() || "omp"; + const resolved = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(resolved.command, resolved.args, { + env: environment, + shell: resolved.shell, + }), + ); + }); +} + +function readOptionalOmpConfigValue( + settings: Pick, + key: string, + environment: NodeJS.ProcessEnv, +) { + return runOmpCommand(settings, ["config", "get", key, "--json"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result) => { + if (Result.isFailure(result) || Option.isNone(result.success)) return undefined; + const commandResult = result.success.value; + return commandResult.code === 0 ? parseOmpConfigValue(commandResult.stdout) : undefined; + }), + ); +} + +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 fallback: ReadonlyArray = []; + if (!settings.enabled) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: false, + checkedAt, + models: fallback, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Oh My Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionExit = yield* runOmpCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionExit)) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: !isCommandMissingCause(versionExit.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionExit.failure) + ? "Oh My Pi (`omp`) is not installed or not on PATH." + : "Failed to execute the Oh My Pi version check.", + }, + }); + } + if (Option.isNone(versionExit.success)) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi is installed but timed out while running `omp --version`.", + }, + }); + } + + const versionResult = versionExit.success.value; + const version = parseGenericCliVersion(`${versionResult.stdout}\n${versionResult.stderr}`); + if (versionResult.code !== 0 || version === null) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi is installed but failed to report a valid version.", + }, + }); + } + if (compareSemverVersions(version, OMP_MINIMUM_VERSION) < 0) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Oh My Pi v${version} is too old. Upgrade to v${OMP_MINIMUM_VERSION} or newer.`, + }, + }); + } + + const profile = ompProfileFromLaunchArgs(settings.launchArgs); + const modelsEnvironment = profile ? { ...environment, OMP_PROFILE: profile } : environment; + const modelsCommandArgs = ompModelsCommandArgs(settings.launchArgs); + const hasConfigOverlays = modelsCommandArgs.length > 3; + const modelsExit = yield* runOmpCommand(settings, modelsCommandArgs, modelsEnvironment).pipe( + Effect.timeoutOption(MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(modelsExit) || Option.isNone(modelsExit.success)) { + const timedOut = Result.isSuccess(modelsExit) && Option.isNone(modelsExit.success); + yield* Effect.logWarning("Oh My Pi model discovery failed", { + errorTag: Result.isFailure(modelsExit) ? "process-error" : "timeout", + }); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: timedOut + ? `Oh My Pi model discovery timed out after ${MODEL_DISCOVERY_TIMEOUT_MS}ms.` + : "Failed to list Oh My Pi models. Check server logs for details.", + }, + }); + } + + const modelsResult = modelsExit.success.value; + if (modelsResult.code !== 0) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Failed to list Oh My Pi models. Check server logs for details.", + }, + }); + } + + const configDefaults = !hasConfigOverlays + ? yield* Effect.all( + { + modelRoles: readOptionalOmpConfigValue(settings, "modelRoles", modelsEnvironment), + defaultThinking: readOptionalOmpConfigValue( + settings, + "defaultThinkingLevel", + modelsEnvironment, + ), + }, + { concurrency: "unbounded" }, + ) + : { modelRoles: undefined, defaultThinking: undefined }; + const discoveredModels = parseOmpModelsJson(modelsResult.stdout, { + defaultModelRole: trimmedString(asRecord(configDefaults.modelRoles)?.default), + defaultThinking: trimmedString(configDefaults.defaultThinking), + }); + if (!discoveredModels) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi returned invalid model data.", + }, + }); + } + const catalogModels = hasConfigOverlays + ? [ + { + slug: "default", + name: "OMP config default", + isCustom: false, + isDefault: true, + capabilities: null, + } satisfies ServerProviderModel, + ...discoveredModels, + ] + : discoveredModels; + const models = providerModelsFromSettings( + catalogModels, + settings.customModels, + EMPTY_CAPABILITIES, + ); + if (models.length === 0) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models: fallback, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi returned no models. Run `omp setup` and select a model.", + }, + }); + } + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichOmpSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + Effect.catchCause((cause) => + Effect.logWarning("Oh My Pi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..70f56244d5c8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,17 +10,17 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `omp`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform - * across every provider — any driver plugs into the registry through + * across every provider. Any driver plugs into the registry through * the same `ProviderDriver` value contract. * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. + * without trying to spawn real provider binaries. That keeps the assertions + * focused on registry routing behavior rather than the runtime details of each + * provider. */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -29,6 +29,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type OmpSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -47,6 +48,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; +import { OmpDriver } from "../Drivers/OmpDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -133,6 +135,14 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeOmpConfig = (overrides: Partial): OmpSettings => ({ + enabled: false, + binaryPath: "omp", + launchArgs: "", + 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 @@ -321,12 +331,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const ompId = ProviderInstanceId.make("omp_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const ompDriverKind = ProviderDriverKind.make("omp"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -362,10 +374,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [ompId]: { + driver: ompDriverKind, + displayName: "Oh My Pi", + enabled: false, + config: makeOmpConfig({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver, OmpDriver], configMap, }); @@ -375,9 +393,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, ompId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -388,16 +406,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const omp = yield* registry.getInstance(ompId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(omp?.driverKind).toBe(ompDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(omp?.displayName).toBe("Oh My Pi"); // Every instance owns its own set of closures — no sharing across // drivers. `adapter` / `textGeneration` / `snapshot` are all @@ -410,6 +431,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.adapter, grok!.adapter, openCode!.adapter, + omp!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -418,6 +440,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + omp!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -426,6 +449,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + omp!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -468,6 +492,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/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts new file mode 100644 index 000000000000..c1e8c56873ed --- /dev/null +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -0,0 +1,11 @@ +/** + * OmpAdapter: shape type for the Oh My Pi ACP provider adapter. + * + * The driver bundles one adapter per provider instance. The runtime + * implementation lives beside the other ACP adapters and is intentionally + * kept separate from this type-only service boundary. + */ +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/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..2fbaa32981ca 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import { + configOptionCurrentValueMatches, extractModelConfigId, + extractConfigOptionsFromSessionUpdate, + findSessionConfigOption, mergeToolCallState, parsePermissionRequest, parseSessionModeState, @@ -61,6 +64,38 @@ describe("AcpRuntimeModel", () => { expect(modelConfigId).toBe("model"); }); + it("uses native config updates when deciding whether a later model change is needed", () => { + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "config_option_update", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "native-model", + options: [ + { value: "native-model", name: "Native model" }, + { value: "requested-model", name: "Requested model" }, + ], + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification; + + const configOptions = extractConfigOptionsFromSessionUpdate(notification); + const modelOption = findSessionConfigOption(configOptions, "model"); + + expect(modelOption?.currentValue).toBe("native-model"); + expect(modelOption).toBeDefined(); + if (modelOption) { + expect(configOptionCurrentValueMatches(modelOption, "native-model")).toBe(true); + expect(configOptionCurrentValueMatches(modelOption, "requested-model")).toBe(false); + } + }); + it("detects Grok session replay updates from _meta.isReplay", () => { expect( sessionUpdateIsReplay({ @@ -336,6 +371,89 @@ describe("AcpRuntimeModel", () => { ]); }); + it("projects typed ACP usage and available command updates", () => { + const usageResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 1234, + size: 8192, + cost: { + amount: 0.42, + currency: "USD", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(usageResult.events).toEqual([ + { + _tag: "UsageUpdated", + payload: { + used: 1234, + size: 8192, + cost: { + amount: 0.42, + currency: "USD", + }, + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 1234, + size: 8192, + cost: { + amount: 0.42, + currency: "USD", + }, + }, + }, + }, + ]); + + const commandsResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "review", + description: "Review the current changes", + input: { hint: "optional focus" }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(commandsResult.events).toEqual([ + { + _tag: "AvailableCommandsUpdated", + payload: { + availableCommands: [ + { + name: "review", + description: "Review the current changes", + input: { hint: "optional focus" }, + }, + ], + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "review", + description: "Review the current changes", + input: { hint: "optional focus" }, + }, + ], + }, + }, + }, + ]); + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..e8a22c9d203f 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -108,6 +108,16 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly payload: EffectAcpSchema.UsageUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly payload: EffectAcpSchema.AvailableCommandsUpdate; + readonly rawPayload: unknown; }; type AcpSessionSetupResponse = @@ -145,6 +155,28 @@ export function findSessionConfigOption( return configOptions.find((option) => option.id.trim() === normalizedConfigId); } +export function extractConfigOptionsFromSessionUpdate( + params: EffectAcpSchema.SessionNotification, +): ReadonlyArray | undefined { + return params.update.sessionUpdate === "config_option_update" + ? params.update.configOptions + : undefined; +} + +export function configOptionCurrentValueMatches( + configOption: EffectAcpSchema.SessionConfigOption, + value: string | boolean, +): boolean { + const currentValue = configOption.currentValue; + if (configOption.type === "boolean") { + return currentValue === value; + } + if (typeof currentValue !== "string") { + return false; + } + return currentValue.trim() === String(value).trim(); +} + export function collectSessionConfigOptionValues( configOption: EffectAcpSchema.SessionConfigOption, ): ReadonlyArray { @@ -574,6 +606,32 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "usage_update": { + const payload: EffectAcpSchema.UsageUpdate = { + ...(upd._meta !== undefined ? { _meta: upd._meta } : {}), + ...(upd.cost !== undefined ? { cost: upd.cost } : {}), + size: upd.size, + used: upd.used, + }; + events.push({ + _tag: "UsageUpdated", + payload, + rawPayload: params, + }); + break; + } + case "available_commands_update": { + const payload: EffectAcpSchema.AvailableCommandsUpdate = { + ...(upd._meta !== undefined ? { _meta: upd._meta } : {}), + availableCommands: upd.availableCommands, + }; + events.push({ + _tag: "AvailableCommandsUpdated", + payload, + rawPayload: params, + }); + break; + } default: break; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..bce79db68147 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -22,8 +22,10 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { + configOptionCurrentValueMatches, collectSessionConfigOptionValues, extractModelConfigId, + extractConfigOptionsFromSessionUpdate, findSessionConfigOption, mergeToolCallState, parseSessionModeState, @@ -393,6 +395,10 @@ export const make = ( ) { return; } + const configOptions = extractConfigOptionsFromSessionUpdate(notification); + if (configOptions) { + yield* Ref.set(configOptionsRef, configOptions); + } yield* handleSessionUpdate({ queue: eventQueue, modeStateRef, @@ -827,20 +833,6 @@ function sessionConfigOptionsFromSetup( return response?.configOptions ?? []; } -function configOptionCurrentValueMatches( - configOption: EffectAcpSchema.SessionConfigOption, - value: string | boolean, -): boolean { - const currentValue = configOption.currentValue; - if (configOption.type === "boolean") { - return currentValue === value; - } - if (typeof currentValue !== "string") { - return false; - } - return currentValue.trim() === String(value).trim(); -} - const handleSessionUpdate = ({ queue, modeStateRef, 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..a52fe6779689 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + applyOmpAcpModelSelection, + buildOmpAcpSpawnInput, + buildOmpTextGenerationAcpSpawnInput, + resolveOmpAcpConfigUpdates, + shouldAutoApproveOmpPermission, +} from "./OmpAcpSupport.ts"; + +describe("buildOmpAcpSpawnInput", () => { + it("starts the ACP subcommand before configured launch arguments", () => { + expect( + buildOmpAcpSpawnInput( + { binaryPath: "/usr/local/bin/omp", launchArgs: '--profile "work profile"' }, + "/tmp/project", + { OMP_TEST: "1" }, + ), + ).toEqual({ + command: "/usr/local/bin/omp", + args: ["acp", "--profile", "work profile"], + cwd: "/tmp/project", + env: { OMP_TEST: "1" }, + }); + }); + + it.each([ + ["approval-required", "always-ask"], + ["auto", "always-ask"], + ["auto-accept-edits", "write"], + ["full-access", "yolo"], + ] as const)("enforces the %s runtime mode", (runtimeMode, approvalMode) => { + expect( + buildOmpAcpSpawnInput( + { + binaryPath: "omp", + launchArgs: + "--profile work --yolo --auto-approve --approval-mode=write --approval-mode always-ask", + }, + "/tmp/project", + undefined, + runtimeMode, + ).args, + ).toEqual(["acp", "--profile", "work", "--approval-mode", approvalMode]); + }); +}); + +describe("buildOmpTextGenerationAcpSpawnInput", () => { + it("keeps one unambiguous profile and ignores other launch arguments", () => { + expect( + buildOmpTextGenerationAcpSpawnInput( + { + binaryPath: "/usr/local/bin/omp", + launchArgs: + '--profile "text generation" --tools bash --yolo -e /tmp/unsafe.ts --extension /tmp/unsafe-two.ts', + }, + "/tmp/project", + "/tmp/omp-session", + ), + ).toEqual({ + command: "/usr/local/bin/omp", + args: [ + "acp", + "--profile", + "text generation", + "--session-dir", + "/tmp/omp-session", + "--no-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-rules", + "--approval-mode", + "always-ask", + ], + cwd: "/tmp/project", + }); + }); + + it("drops ambiguous profile arguments", () => { + const expectedArgs = [ + "acp", + "--session-dir", + "/tmp/omp-session", + "--no-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-rules", + "--approval-mode", + "always-ask", + ]; + + for (const launchArgs of ["--profile safe --profile unsafe", '--profile "unterminated']) { + expect( + buildOmpTextGenerationAcpSpawnInput( + { binaryPath: "omp", launchArgs }, + "/tmp/project", + "/tmp/omp-session", + ).args, + ).toEqual(expectedArgs); + } + }); +}); + +describe("shouldAutoApproveOmpPermission", () => { + const editRequest = { + kind: "unknown", + toolCall: { + toolCallId: "edit-1", + status: "pending", + data: { locations: [{ path: "/tmp/file.ts" }] }, + }, + } as const; + const commandRequest = { + kind: "execute", + toolCall: { + toolCallId: "bash-1", + status: "pending", + command: "npm test", + data: {}, + }, + } as const; + + it("auto-approves OMP edit gates only in edit-accepting modes", () => { + expect(shouldAutoApproveOmpPermission("auto-accept-edits", editRequest)).toBe(true); + expect(shouldAutoApproveOmpPermission("approval-required", editRequest)).toBe(false); + expect(shouldAutoApproveOmpPermission("auto", editRequest)).toBe(false); + expect( + shouldAutoApproveOmpPermission("auto-accept-edits", { + kind: "unknown", + toolCall: { toolCallId: "unknown-1", status: "pending", data: {} }, + }), + ).toBe(false); + }); + + it("keeps commands gated unless the session has full access", () => { + expect(shouldAutoApproveOmpPermission("auto-accept-edits", commandRequest)).toBe(false); + expect(shouldAutoApproveOmpPermission("full-access", commandRequest)).toBe(true); + }); +}); + +describe("applyOmpAcpModelSelection", () => { + it.effect("keeps the OMP default model and applies thinking selection", () => + Effect.gen(function* () { + const modelCalls: string[] = []; + const configCalls: Array<[string, string | boolean]> = []; + const result = yield* applyOmpAcpModelSelection({ + runtime: { + getConfigOptions: Effect.succeed([ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, + ]), + setConfigOption: (id, value) => + Effect.sync(() => { + configCalls.push([id, value]); + }), + setModel: (model) => Effect.sync(() => void modelCalls.push(model)), + }, + model: "default", + selections: [{ id: "thinking", value: "high" }], + mapError: (context) => context.step, + }); + + expect(result).toBeUndefined(); + expect(modelCalls).toEqual([]); + expect(configCalls).toEqual([["thinking", "high"]]); + }), + ); +}); + +describe("resolveOmpAcpConfigUpdates", () => { + it("matches configured values case-insensitively", () => { + expect( + resolveOmpAcpConfigUpdates( + [ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, + ], + [{ id: "thinking", value: "HIGH" }], + ), + ).toEqual([{ 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..e97e4af1b430 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -0,0 +1,603 @@ +import { + type OmpSettings, + type ProviderApprovalDecision, + type ProviderOptionSelection, + type ProviderUserInputAnswers, + type RuntimeMode, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { getProviderOptionStringSelectionValue } from "@t3tools/shared/model"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + type AcpPermissionRequest, + collectSessionConfigOptionValues, +} from "./AcpRuntimeModel.ts"; + +export const OMP_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; + +export const OMP_ACP_CLIENT_CAPABILITIES = { + elicitation: { form: {} }, +} satisfies NonNullable; + +export interface OmpAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly ompSettings: Pick | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +export interface OmpAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseOmpResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== OMP_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) return exact; + } + return normalizedAliases + .map((alias) => modes.find((mode) => normalizeModeSearchText(mode).includes(alias))) + .find((mode) => mode !== undefined); +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function requestedOmpModeId(modeState: AcpSessionModeState | undefined): string | undefined { + if (!modeState) return undefined; + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + (modeState.availableModes.some( + (mode) => mode.id === modeState.currentModeId && !isPlanMode(mode), + ) + ? modeState.currentModeId + : undefined) + ); +} + +export function applyOmpRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyOmpAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => input.mapError({ cause, method: "session/set_config_option" }), + }); + } + + const modeId = requestedOmpModeId(yield* input.runtime.getModeState); + if (!modeId) return; + yield* input.runtime + .setMode(modeId) + .pipe(Effect.mapError((cause) => input.mapError({ cause, method: "session/set_mode" }))); + }); +} + +export function selectAutoApprovedOmpPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + return typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim() + ? allowOnceOption.optionId.trim() + : undefined; +} + +export function shouldAutoApproveOmpPermission( + runtimeMode: RuntimeMode, + request: AcpPermissionRequest, +): boolean { + if (runtimeMode === "full-access") return true; + const locations = request.toolCall?.data.locations; + return ( + runtimeMode === "auto-accept-edits" && + request.kind !== "execute" && + request.toolCall?.command === undefined && + Array.isArray(locations) && + locations.length > 0 + ); +} + +export function selectOmpPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const preferredKinds = + decision === "acceptForSession" + ? (["allow_always", "allow_once"] as const) + : decision === "accept" + ? (["allow_once", "allow_always"] as const) + : (["reject_once", "reject_always"] as const); + for (const kind of preferredKinds) { + const option = request.options.find((candidate) => candidate.kind === kind); + if (typeof option?.optionId === "string" && option.optionId.trim()) { + return option.optionId.trim(); + } + } + return undefined; +} + +export interface OmpElicitationQuestion { + readonly key: string; + readonly otherKey?: string; + readonly schema: EffectAcpSchema.ElicitationPropertySchema; + readonly question: UserInputQuestion; +} + +const CUSTOM_ANSWER_OPTION = { + label: "Type a response", + description: "Enter your answer in the message box.", +} as const; + +function enumChoices( + schema: EffectAcpSchema.ElicitationPropertySchema, +): ReadonlyArray<{ readonly value: string; readonly label: string }> { + if (schema.type === "string") { + if (schema.oneOf && schema.oneOf.length > 0) { + return schema.oneOf.map((choice) => ({ value: choice.const, label: choice.title })); + } + return (schema.enum ?? []).map((value) => ({ value, label: value })); + } + if (schema.type !== "array") return []; + return "anyOf" in schema.items + ? schema.items.anyOf.map((choice) => ({ value: choice.const, label: choice.title })) + : schema.items.enum.map((value) => ({ value, label: value })); +} + +export function ompElicitationQuestions( + request: Extract, +): ReadonlyArray { + const properties = request.requestedSchema.properties ?? {}; + const entries = Object.entries(properties).filter(([key]) => !key.endsWith("__other")); + return entries.map(([key, schema], index) => { + const choices = enumChoices(schema); + const options = + schema.type === "boolean" + ? [ + { label: "Yes", description: "Confirm this action." }, + { label: "No", description: "Do not confirm this action." }, + ] + : choices.length > 0 + ? choices.map((choice) => ({ + label: choice.label.trim() || choice.value, + description: choice.label.trim() || choice.value, + })) + : [CUSTOM_ANSWER_OPTION]; + const title = schema.title?.trim(); + const description = schema.description?.trim(); + const header = + request.requestedSchema.title?.trim() || + (description && description.length <= 48 ? description : undefined) || + `Question ${index + 1}`; + return { + key, + ...(properties[`${key}__other`] ? { otherKey: `${key}__other` } : {}), + schema, + question: { + id: key, + header, + question: title || request.message.trim() || `Answer question ${index + 1}.`, + options, + ...(schema.type === "array" ? { multiSelect: true } : {}), + }, + }; + }); +} + +function normalizeElicitationAnswer( + answer: unknown, + schema: EffectAcpSchema.ElicitationPropertySchema, +): EffectAcpSchema.ElicitationContentValue | undefined { + if (schema.type === "array") { + const values = Array.isArray(answer) ? answer : typeof answer === "string" ? [answer] : []; + const allowed = new Set(enumChoices(schema).map((choice) => choice.value)); + const normalized = values.filter( + (value): value is string => typeof value === "string" && allowed.has(value), + ); + return normalized.length > 0 ? normalized : undefined; + } + const scalar = Array.isArray(answer) ? answer[0] : answer; + if (schema.type === "boolean") { + if (typeof scalar === "boolean") return scalar; + if (typeof scalar !== "string") return undefined; + const normalized = scalar.trim().toLowerCase(); + if (["yes", "true", "1"].includes(normalized)) return true; + if (["no", "false", "0"].includes(normalized)) return false; + return undefined; + } + if (schema.type === "number" || schema.type === "integer") { + const value = typeof scalar === "number" ? scalar : Number(scalar); + if (!Number.isFinite(value)) return undefined; + return schema.type === "integer" && !Number.isInteger(value) ? undefined : value; + } + return typeof scalar === "string" && scalar.trim().length > 0 ? scalar.trim() : undefined; +} + +export function buildOmpElicitationContent( + questions: ReadonlyArray, + answers: ProviderUserInputAnswers, +): Record { + const content: Record = {}; + for (const question of questions) { + const rawAnswer = answers[question.key]; + const normalized = normalizeElicitationAnswer(rawAnswer, question.schema); + const allowed = new Set(enumChoices(question.schema).map((choice) => choice.value)); + const scalarAnswer = Array.isArray(rawAnswer) ? rawAnswer[0] : rawAnswer; + if ( + question.otherKey && + typeof scalarAnswer === "string" && + scalarAnswer.trim().length > 0 && + !allowed.has(scalarAnswer.trim()) + ) { + content[question.otherKey] = scalarAnswer.trim(); + continue; + } + if (normalized !== undefined) content[question.key] = normalized; + } + return content; +} + +export function buildOmpAcpSpawnInput( + ompSettings: Pick | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, +): AcpSessionRuntime.AcpSpawnInput { + const configuredArgs = tokenizeCliArgs(ompSettings?.launchArgs); + const launchArgs = runtimeMode + ? [...withoutOmpApprovalArgs(configuredArgs), "--approval-mode", ompApprovalMode(runtimeMode)] + : configuredArgs; + return { + command: ompSettings?.binaryPath?.trim() || "omp", + args: ["acp", ...launchArgs], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +function withoutOmpApprovalArgs(args: ReadonlyArray): ReadonlyArray { + const safeArgs: Array = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if ( + arg === "--auto-approve" || + arg === "--yolo" || + arg?.startsWith("--auto-approve=") || + arg?.startsWith("--yolo=") || + arg?.startsWith("--approval-mode=") + ) { + continue; + } + if (arg === "--approval-mode") { + index += 1; + continue; + } + if (arg !== undefined) safeArgs.push(arg); + } + return safeArgs; +} + +export function ompApprovalMode(runtimeMode: RuntimeMode): "always-ask" | "write" | "yolo" { + switch (runtimeMode) { + case "full-access": + return "yolo"; + case "auto-accept-edits": + return "write"; + case "approval-required": + case "auto": + return "always-ask"; + } +} + +const OMP_TEXT_GENERATION_ACP_ARGS = [ + "--no-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-rules", + "--approval-mode", + "always-ask", +] as const; + +function hasBalancedCliQuotes(launchArgs: string | undefined): boolean { + if (!launchArgs) return true; + + let quote: "'" | '"' | undefined; + for (let index = 0; index < launchArgs.length; index++) { + const char = launchArgs[index]; + if (char === undefined) continue; + + if (quote) { + if (char === quote) { + quote = undefined; + } else if (char === "\\" && quote === '"') { + index++; + } + continue; + } + + if (char === "'" || char === '"') quote = char; + } + + return quote === undefined; +} + +export function ompProfileFromLaunchArgs(launchArgs: string | undefined): string | undefined { + if (!hasBalancedCliQuotes(launchArgs)) return undefined; + + let profile: string | undefined; + const args = tokenizeCliArgs(launchArgs); + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--profile") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return undefined; + if (profile !== undefined) return undefined; + profile = value; + index++; + continue; + } + + if (arg?.startsWith("--profile=")) { + const value = arg.slice("--profile=".length); + if (!value || value.startsWith("-")) return undefined; + if (profile !== undefined) return undefined; + profile = value; + } + } + + return profile; +} + +function textGenerationProfileArgs(launchArgs: string | undefined): ReadonlyArray { + const profile = ompProfileFromLaunchArgs(launchArgs); + return profile ? ["--profile", profile] : []; +} + +export function buildOmpTextGenerationAcpSpawnInput( + ompSettings: Pick | null | undefined, + cwd: string, + sessionDir: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: ompSettings?.binaryPath?.trim() || "omp", + args: [ + "acp", + ...textGenerationProfileArgs(ompSettings?.launchArgs), + "--session-dir", + sessionDir, + ...OMP_TEXT_GENERATION_ACP_ARGS, + ], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +const makeOmpAcpRuntimeWithSpawn = ( + input: OmpAcpRuntimeInput, + spawn: AcpSessionRuntime.AcpSpawnInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn, + authMethodId: "agent", + clientCapabilities: OMP_ACP_CLIENT_CAPABILITIES, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export const makeOmpAcpRuntime = ( + input: OmpAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + makeOmpAcpRuntimeWithSpawn( + input, + buildOmpAcpSpawnInput(input.ompSettings, input.cwd, input.environment, input.runtimeMode), + ); + +export const makeOmpTextGenerationAcpRuntime = ( + input: OmpAcpRuntimeInput & { readonly sessionDir: string }, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + makeOmpAcpRuntimeWithSpawn( + input, + buildOmpTextGenerationAcpSpawnInput( + input.ompSettings, + input.cwd, + input.sessionDir, + input.environment, + ), + ); + +interface OmpAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +function normalizeConfigValue(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-"); +} + +function findConfigOptionByCategory( + options: ReadonlyArray, + category: string, + names: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + const normalizedNames = names.map(normalizeConfigValue); + return ( + options.find((option) => option.category?.trim().toLowerCase() === category) ?? + options.find((option) => { + const id = normalizeConfigValue(option.id); + const name = normalizeConfigValue(option.name); + return normalizedNames.some((candidate) => id === candidate || name.includes(candidate)); + }) + ); +} + +function resolveConfigValue( + option: EffectAcpSchema.SessionConfigOption | undefined, + requested: string | undefined, +): string | boolean | undefined { + if (!option || requested === undefined) return undefined; + if (option.type === "boolean") { + const normalized = normalizeConfigValue(requested); + if (normalized === "true") return true; + if (normalized === "false") return false; + return undefined; + } + const wanted = normalizeConfigValue(requested); + return collectSessionConfigOptionValues(option).find( + (value) => normalizeConfigValue(value) === wanted, + ); +} + +export function resolveOmpAcpConfigUpdates( + configOptions: ReadonlyArray | null | undefined, + selections: ReadonlyArray | null | undefined, +): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { + if (!configOptions || configOptions.length === 0) return []; + + const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const thinkingOption = findConfigOptionByCategory(configOptions, "thought_level", [ + "thinking", + "reasoning", + "effort", + ]); + const thinkingValue = resolveConfigValue( + thinkingOption, + getProviderOptionStringSelectionValue(selections, "thinking") ?? + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); + if (thinkingOption && thinkingValue !== undefined) { + updates.push({ configId: thinkingOption.id, value: thinkingValue }); + } + + const modeOption = findConfigOptionByCategory(configOptions, "mode", ["mode"]); + const modeValue = resolveConfigValue( + modeOption, + getProviderOptionStringSelectionValue(selections, "mode"), + ); + if (modeOption && modeValue !== undefined) { + updates.push({ configId: modeOption.id, value: modeValue }); + } + + return updates; +} + +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 && model !== "default") { + yield* input.runtime + .setModel(model) + .pipe(Effect.mapError((cause) => input.mapError({ cause, step: "set-model" }))); + } + + const configOptions = yield* input.runtime.getConfigOptions; + for (const update of resolveOmpAcpConfigUpdates(configOptions, input.selections)) { + yield* input.runtime + .setConfigOption(update.configId, update.value) + .pipe( + Effect.mapError((cause) => + input.mapError({ cause, step: "set-config-option", configId: update.configId }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..5300e6908a85 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OmpDriver, type OmpDriverEnv } from "./Drivers/OmpDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | OmpDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): string { + const wrapperPath = NodePath.join(dir, "omp"); + NodeFS.writeFileSync( + wrapperPath, + [ + "#!/bin/sh", + ...Object.entries(environment).map( + ([key, value]) => `export ${key}=${shellSingleQuote(value)}`, + ), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + 'if [ -n "${T3_OMP_ARGV_PATH:-}" ]; then', + ' printf "%s\\n" "$@" > "$T3_OMP_ARGV_PATH"', + "fi", + `exec node ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(wrapperPath, 0o755); + return wrapperPath; +} + +it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { + it.effect("starts ACP with an isolated command", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const argvPath = NodePath.join(tempDir, "argv.txt"); + const binaryPath = makeAcpAgentWrapper(tempDir, { + T3_OMP_ARGV_PATH: argvPath, + T3_ACP_PROMPT_RESPONSE_TEXT: '{"title":"Safe generated title"}', + }); + const textGeneration = yield* makeOmpTextGeneration( + decodeOmpSettings({ + binaryPath, + launchArgs: + "--tools bash --yolo -e /tmp/unsafe.ts --extension /tmp/unsafe-two.ts --hook /tmp/unsafe-three.ts", + }), + ); + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Write a short title.", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }); + + expect(generated.title).toBe("Safe generated title"); + const argv = NodeFS.readFileSync(argvPath, "utf8").trim().split("\n"); + expect(argv.slice(0, 2)).toEqual(["acp", "--session-dir"]); + const sessionDir = argv[2]; + expect(sessionDir).toBeDefined(); + expect(argv.slice(3)).toEqual([ + "--no-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-rules", + "--approval-mode", + "always-ask", + ]); + expect(NodeFS.existsSync(sessionDir!)).toBe(false); + }).pipe(Effect.scoped), + ); + + it.effect("denies ACP tool permission requests", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const binaryPath = makeAcpAgentWrapper(tempDir, { + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const textGeneration = yield* makeOmpTextGeneration(decodeOmpSettings({ binaryPath })); + + yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Write a short title.", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }) + .pipe(Effect.exit); + + const messages = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); + expect(messages).toContainEqual( + expect.objectContaining({ + result: { + outcome: { outcome: "cancelled" }, + }, + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect("sends image attachments as ACP image blocks", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const binaryPath = makeAcpAgentWrapper(tempDir, { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: '{"title":"Image title"}', + }); + const serverConfig = yield* ServerConfig.ServerConfig; + const attachment = decodeChatAttachment({ + type: "image", + id: "omp-image-123e4567-e89b-12d3-a456-426614174000", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + }); + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + expect(attachmentPath).not.toBeNull(); + if (!attachmentPath) return; + NodeFS.mkdirSync(serverConfig.attachmentsDir, { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Buffer.from([1, 2, 3, 4])); + + const textGeneration = yield* makeOmpTextGeneration(decodeOmpSettings({ binaryPath })); + yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this screenshot.", + attachments: [attachment], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "default", + }, + }); + + const messages = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(messages).toContainEqual( + expect.objectContaining({ + method: "session/prompt", + params: expect.objectContaining({ + prompt: expect.arrayContaining([ + { + type: "image", + data: "AQIDBA==", + mimeType: "image/png", + }, + ]), + }), + }), + ); + }).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..a45bae2cceec --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -0,0 +1,286 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +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 * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + type ChatAttachment, + type ModelSelection, + type OmpSettings, + TextGenerationError, +} from "@t3tools/contracts"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyOmpAcpModelSelection, + makeOmpTextGenerationAcpRuntime, +} from "../provider/acp/OmpAcpSupport.ts"; + +const OMP_TIMEOUT_MS = 180_000; +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function* ( + ompSettings: OmpSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + + const imagePromptParts = Effect.fn("OmpTextGeneration.imagePromptParts")(function* ( + attachments: ReadonlyArray | undefined, + ) { + const parts: Array = []; + for (const attachment of attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) continue; + const bytes = yield* fileSystem.readFile(attachmentPath).pipe(Effect.option); + if (Option.isNone(bytes)) continue; + parts.push({ + type: "image", + data: Buffer.from(bytes.value).toString("base64"), + mimeType: attachment.mimeType, + }); + } + return parts; + }); + + const runOmpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + attachments, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + attachments?: ReadonlyArray | undefined; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const sessionDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-omp-text-session-", + }); + const runtime = yield* makeOmpTextGenerationAcpRuntime({ + ompSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + sessionDir, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") return Effect.void; + const content = update.content; + if (content.type !== "text") return Effect.void; + return Ref.update(outputRef, (current) => current + content.text); + }); + yield* runtime.handleElicitation(() => + Effect.succeed({ action: { action: "cancel" as const } }), + ); + yield* runtime.handleRequestPermission(() => + Effect.succeed({ outcome: { outcome: "cancelled" as const } }), + ); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* applyOmpAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + new TextGenerationError({ + operation, + detail: + step === "set-config-option" + ? `Failed to set Oh My Pi ACP config option "${configId}" for text generation.` + : "Failed to set Oh My Pi ACP model for text generation.", + cause, + }), + }); + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }, ...(yield* imagePromptParts(attachments))], + }); + }).pipe( + Effect.timeoutOption(OMP_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Oh My Pi request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Oh My Pi ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Oh My Pi ACP request was cancelled." + : "Oh My Pi returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Oh My Pi returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Oh My Pi ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OmpTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runOmpJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OmpTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runOmpJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OmpTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOmpJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OmpTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runOmpJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..e2bd08816e85 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode" + | "omp"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md index c9a675ef41c9..0da82d298120 100644 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -9,3 +9,17 @@ Copyright (c) 2016 Roberto Huertas Licensed under the MIT License. The full license text is available in the upstream repository: . + +## Oh My Pi + +The Oh My Pi provider icon in `src/components/Icons.tsx` is adapted from the +[`oh-my-pi`](https://github.com/can1357/oh-my-pi) project. + +Copyright (c) 2025 Mario Zechner + +Copyright (c) 2025-2026 Can Bölük + +Copyright (c) 2026 Stencil Labs, Inc. + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..2577dfd18de2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -666,6 +666,47 @@ export const OpenCodeIcon: Icon = (props) => ( ); +// Adapted from oh-my-pi/assets/icon.svg. +export const OmpIcon: Icon = (props) => ( + + + + + + + + + + + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, + [ProviderDriverKind.make("omp")]: OmpIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, }; diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..a07872627b6d 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode|omp|cursor)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..9c4a0b34398f 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,15 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("exposes the OMP binary and launch argument fields", () => { + const omp = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("omp")]; + expect(omp).toBeDefined(); + expect(deriveProviderSettingsFields(omp!).map((field) => field.key)).toEqual([ + "binaryPath", + "launchArgs", + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..79869540545c 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,12 @@ import { CodexSettings, CursorSettings, GrokSettings, + OmpSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OmpIcon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +68,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("omp"), + label: "Oh My Pi", + icon: OmpIcon, + settingsSchema: OmpSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..a4fd5a0b9baf 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -812,7 +812,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode", "omp"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -971,7 +971,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "opencode", "omp"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2773,7 +2773,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "opencode", "omp"] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c3226884a31d..930df129a0a0 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; +import { + deriveLatestContextWindowSnapshot, + formatContextWindowTokens, + formatProviderDisplayName, +} from "./contextWindow"; function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { return { @@ -16,6 +20,10 @@ function makeActivity(id: string, kind: string, payload: unknown): Orchestration } describe("contextWindow", () => { + it("formats the Oh My Pi provider name", () => { + expect(formatProviderDisplayName("omp")).toBe("Oh My Pi"); + }); + it("derives the latest valid context window snapshot", () => { const snapshot = deriveLatestContextWindowSnapshot([ makeActivity("activity-1", "context-window.updated", { diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..a06aac8970ca 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "omp": + return "Oh My Pi"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..db021c08717a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -40,6 +40,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("omp"), + label: "Oh My Pi", + available: true, + pickerSidebarBadge: "new", + }, { value: ProviderDriverKind.make("cursor"), label: "Cursor", diff --git a/docs/README.md b/docs/README.md index 622d81064387..d934b9a6ba42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Oh My Pi](./user/providers-omp.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..40750abecc1c 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and Oh My Pi. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..2e147111cd73 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ OpenCode, Oh My Pi │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, OpenCode, and Oh My Pi. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..76226e2f84dc 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -16,6 +16,7 @@ orchestration layer does not know which one is behind a thread. | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `omp` | [`Drivers/OmpDriver.ts`][omp] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -36,8 +37,9 @@ Two registries separate configuration from live processes: [`ProviderService`][service] sits on top. It combines the adapter registry with the provider session directory to route session and turn operations for a thread, so callers name a thread, not an agent. -Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No -orchestration, contract, or client change is required for the common case. +Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. A +driver with user-facing settings or picker metadata also needs its schema and client metadata in +`packages/contracts`, `apps/web`, and `apps/mobile`. ## How provider work is requested @@ -81,6 +83,7 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[omp]: ../../apps/server/src/provider/Drivers/OmpDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..e7572faddd81 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -61,9 +61,10 @@ to use, then authenticate it. | Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | | Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | | OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Oh My Pi | [Oh My Pi](https://omp.sh) | `omp` | `omp setup` | -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn -them on in **Settings** → the provider's card when you want to use them. +Codex and Claude are on by default. Cursor, Grok Build, OpenCode, and Oh My Pi are off by default; +turn them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/docs/user/providers-omp.md b/docs/user/providers-omp.md new file mode 100644 index 000000000000..533aa00fa323 --- /dev/null +++ b/docs/user/providers-omp.md @@ -0,0 +1,57 @@ +# Oh My Pi + +Oh My Pi (OMP) is an agent runtime that supports multiple model providers through one CLI. T3 +Code includes OMP as a built-in provider, but it is off by default. + +## Install OMP + +Install OMP on the machine that runs the T3 Code server: + +```bash +curl -fsSL https://omp.sh/install | sh +``` + +You can also install it with Homebrew: + +```bash +brew install can1357/tap/omp +``` + +Run the setup flow and choose a default model: + +```bash +omp setup +``` + +## Enable OMP in T3 Code + +Open **Settings** and enable the Oh My Pi provider. The default binary path is `omp`. Set a +different binary path when OMP is installed outside your `PATH`. Use **Launch arguments** only +when your OMP setup needs extra command-line options. + +Run `omp models` to check that OMP can discover models before starting a T3 Code thread. + +T3 Code uses each model's OMP provider ID in the model picker. This identifies models that have +the same display name, such as models available through both Moonshot and OpenRouter. + +Periodic health checks do not load OMP extensions. If an extension registers a model, add its full +OMP selector under **Custom models**. The normal OMP session loads the extension and validates the +selector when the thread starts. + +## Permission behavior + +T3 Code maps its permission modes to OMP approval modes: + +- **Supervised** and **Auto** use OMP's `always-ask` mode. +- **Auto-accept edits** uses OMP's `write` mode. +- **Full access** uses OMP's `yolo` mode. + +T3 Code controls this mapping for every OMP session. Approval flags in **Launch arguments** cannot +override the selected T3 Code permission mode. + +OMP 17.4.0 can show a second approval form for a supervised shell command or destructive edit. OMP +currently applies its ACP client gate and its native approval gate to those calls. + +OMP does not yet expose the provider-history controls that T3 Code needs for checkpoint rollback or +the T3 Code Plan mode lifecycle. T3 Code hides Plan mode for OMP and reports checkpoint rollback as +unsupported. Follow-up messages wait for the active OMP turn to finish, then start a new turn. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..0cb48292cb56 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OMP_DRIVER_KIND]: "Oh My Pi", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..88524af6e5cc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -187,16 +187,43 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.omp.enabled).toBe(false); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("omp"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); + it("decodes OMP settings and patch fields", () => { + expect(decodeServerSettings({}).providers.omp).toMatchObject({ + enabled: false, + binaryPath: "omp", + launchArgs: "", + customModels: [], + }); + + expect( + decodeServerSettingsPatch({ + providers: { + omp: { + binaryPath: "/opt/omp", + launchArgs: "--config /tmp/omp.yml", + customModels: ["extension/model"], + }, + }, + }).providers?.omp, + ).toEqual({ + binaryPath: "/opt/omp", + launchArgs: "--config /tmp/omp.yml", + customModels: ["extension/model"], + }); + }); + it("resolves instance enabled state with explicit false winning", () => { const grok = ProviderDriverKind.make("grok"); const codex = ProviderDriverKind.make("codex"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..63fcaa392995 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -519,6 +519,43 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const OmpSettings = makeProviderSettingsSchema( + { + // Off by default until the OMP binding is enabled by the user. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("omp").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Oh My Pi binary.", + providerSettingsForm: { placeholder: "omp", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: + "Additional CLI arguments passed after the omp acp command. T3 Code controls approval flags from the thread permission mode.", + providerSettingsForm: { + placeholder: "e.g. --config /path/to/config.yml", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +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(""))), @@ -661,6 +698,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + omp: OmpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -808,6 +846,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), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -849,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + omp: Schema.optionalKey(OmpSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual