From b80bfd70c1b02c720787ac3e66822bfc5db1073f Mon Sep 17 00:00:00 2001 From: omni Date: Wed, 9 Sep 2026 10:24:03 +0100 Subject: [PATCH] feat(server): add Oh My Pi (omp) as an ACP provider Adds omp (https://github.com/can1357/oh-my-pi) as a seventh built-in provider by driving its native stdio ACP server (`omp acp`) through the existing generic ACP client stack (effect-acp), mirroring the Cursor/Grok driver layout: - OmpDriver: provider bundle; manual-only maintenance (T3 never guesses an omp update command); model catalog sourced exclusively from the probe ACP session's configOptions during status checks. - OmpAdapter: session lifecycle on the shared ACP runtime: permission bridging via session/request_permission echoing advertised snake_case option ids, dual elicitation bridging (typed session/elicitation plus the official-SDK ext method elicitation/create with its flat response), task-tool subagent projection into the Agents panel, steering merge, pre-prompt and in-permit cancel checkpoints, and a per-session dispatch lock that serializes the configuration write, the turn.started stamp, and the session/prompt dispatch registration (omp applies model writes to the shared session). The permit is released on dispatch registration or prompt fiber exit (raced), never held across the prompt, so steers stay concurrent; the join carries onInterrupt interruption so post-dispatch cancels cannot orphan a prompt. Rollback is advertised unsupported: the ACP session cannot rewind its native conversation history. - OmpProvider: `omp --version` probe plus ACP model discovery; capabilities attach only to the model the probe session currently runs (trimmed currentValue), other catalog entries report null. Owns both model-option selectors (select-guarded and unguarded existence probe) so their category/id normalization cannot drift. - OmpAcpSupport: spawn args per RuntimeMode (Supervised --approval-mode=always-ask, Auto-accept edits --approval-mode=write, Auto --auto-approve, Full access --approval-mode=yolo); model writes only when the session advertises no model option at all (write through) or advertises the requested slug in its select model option; unadvertised slugs and non-select model options preserve the session's configured model, and the effective model is returned so callers stamp truthful turn/session state. - OmpTextGeneration: unattended commit/PR/branch/title generation with --auto-approve and elicitation disabled. - Contracts: OmpSettings/OmpSettingsPatch, off by default like cursor/grok/opencode; display name "Oh My Pi". - Web/mobile: provider icon, settings metadata, add-provider entry; model rows render the provider and upstream label per model. Tests: adapter/provider/support/text-generation suites on the shared mock ACP agent covering the four model-write cases (no model option, advertised slug, unadvertised slug, non-select model option), flat elicitation responses, prepare-cancel permit release, dispatch serialization order, thread-filtered event consumers, plus picker row label coverage. Validated end to end against a real omp 18.1.15 install (11.9k-model catalog, streamed turn in the built desktop UI). Discussion: pingdotgg/t3code#10883. --- README.md | 5 +- apps/mobile/src/components/ProviderIcon.tsx | 13 + apps/server/scripts/acp-mock-agent.ts | 200 +- apps/server/src/provider/Drivers/OmpDriver.ts | 173 ++ .../src/provider/Layers/OmpAdapter.test.ts | 2473 +++++++++++++++++ apps/server/src/provider/Layers/OmpAdapter.ts | 1649 +++++++++++ .../src/provider/Layers/OmpProvider.test.ts | 458 +++ .../server/src/provider/Layers/OmpProvider.ts | 790 ++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + .../src/provider/Services/OmpAdapter.ts | 19 + .../src/provider/acp/OmpAcpSupport.test.ts | 311 +++ apps/server/src/provider/acp/OmpAcpSupport.ts | 207 ++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/pullRequest/GitHubPullRequestCli.ts | 4 +- .../src/pullRequest/PullRequestService.ts | 4 +- apps/server/src/serverSettings.test.ts | 3 + apps/server/src/serverSettings.ts | 11 +- .../textGeneration/OmpTextGeneration.test.ts | 252 ++ .../src/textGeneration/OmpTextGeneration.ts | 273 ++ apps/web/src/components/Icons.tsx | 11 +- .../chat/ProviderModelPicker.test.tsx | 29 +- .../chat/composerProviderState.test.tsx | 2 +- .../src/components/chat/providerIconUtils.ts | 2 + .../pullRequest/pullRequestList.logic.ts | 4 +- .../settings/AddProviderInstanceDialog.tsx | 14 +- .../components/settings/providerDriverMeta.ts | 9 + docs/user/install.md | 1 + docs/user/permission-modes.md | 4 + packages/contracts/src/model.ts | 2 + packages/contracts/src/settings.test.ts | 10 + packages/contracts/src/settings.ts | 36 +- scripts/lib/cli-external-packages.test.ts | 6 +- 32 files changed, 6933 insertions(+), 46 deletions(-) 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 diff --git a/README.md b/README.md index 27b5dc491693..d6decc24e653 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, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Oh My Pi (omp), OpenCode, and Google Antigravity. 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, OpenCode, and Antigravity. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Oh My Pi (omp), OpenCode, and Antigravity. 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 (omp): install [Oh My Pi](https://github.com/can1357/oh-my-pi) and run `omp` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. ### Try it out (install-free) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..88eca6927c29 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -53,6 +53,19 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "omp") { + return ( + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c923fa401021..2f08c343e9ca 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -49,7 +49,15 @@ const emitStaleXAiPromptCompleteBeforeSecondHang = const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; +// Oh My Pi shape: serve omp-style modes (default/plan) and configOptions +// (mode/model/thinking) instead of the default/Grok shapes. +const ompShapes = process.env.T3_ACP_OMP_SHAPES === "1"; +const emitTaskTool = process.env.T3_ACP_EMIT_TASK_TOOL === "1"; +const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; +const emitTaskToolBatch = process.env.T3_ACP_EMIT_TASK_TOOL_BATCH === "1"; +const emitTaskToolFail = process.env.T3_ACP_EMIT_TASK_TOOL_FAIL === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const setConfigOptionDelayMs = Number(process.env.T3_ACP_SET_CONFIG_OPTION_DELAY_MS ?? "0"); const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const initialGrokReasoningEffort = @@ -67,8 +75,13 @@ const permissionRequestCount = Math.max( ); const sessionId = "mock-session-1"; -let currentModeId = antigravityProfile ? "default" : "ask"; -let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; +let currentModeId = ompShapes || antigravityProfile ? "default" : "ask"; +let currentModelId = ompShapes + ? "zhipu-coding-plan/glm-5.3" + : antigravityProfile + ? "gemini-test-low" + : "default"; +let currentThinking = "high"; let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; @@ -114,6 +127,49 @@ process.once("exit", (code) => { }); function configOptions(): ReadonlyArray { + if (ompShapes) { + return [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "zhipu-coding-plan/glm-5.3", name: "GLM 5.3" }, + { value: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { value: "openai/gpt-5.4", name: "GPT-5.4" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: currentThinking, + options: [ + { value: "off", name: "Off" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ]; + } + if (antigravityProfile) { return [ { @@ -298,29 +354,42 @@ const antigravityModels = [ { modelId: "gemini-test-high", name: "Gemini Test High" }, ] satisfies ReadonlyArray; -const availableModes: ReadonlyArray = antigravityProfile +const availableModes: ReadonlyArray = ompShapes ? [ - { id: "default", name: "Default" }, - { id: "auto_edit", name: "Auto edit" }, - { id: "yolo", name: "YOLO" }, - ] - : [ { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", + id: "default", + name: "Default", + description: "Write and modify code with full tool access", }, { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", + id: "plan", + name: "Plan", + description: "Design and plan without making changes", }, - ]; + ] + : antigravityProfile + ? [ + { id: "default", name: "Default" }, + { id: "auto_edit", name: "Auto edit" }, + { id: "yolo", name: "YOLO" }, + ] + : [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, + ]; function modeState(): AcpSchema.SessionModeState { return { @@ -549,6 +618,9 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { + if (Number.isFinite(setConfigOptionDelayMs) && setConfigOptionDelayMs > 0) { + yield* Effect.sleep(`${setConfigOptionDelayMs} millis`); + } if (exitOnSetConfigOption) { return yield* Effect.sync(() => { process.exit(7); @@ -578,6 +650,9 @@ const program = Effect.gen(function* () { if (request.configId === "fast") { currentFast = request.value === true || request.value === "true"; } + if (request.configId === "thinking" && typeof request.value === "string") { + currentThinking = request.value; + } return { configOptions: configOptions(), }; @@ -885,6 +960,91 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitElicitation) { + // Mirror real omp (official @agent-client-protocol/sdk): the request + // goes out as the extension method `elicitation/create`, and the + // response is the FLAT shape { action: "accept", content } — not + // effect-acp's nested ElicitationResponse. + const result = yield* agent.client.extRequest("elicitation/create", { + sessionId: requestedSessionId, + mode: "form", + message: "Approve this action?", + requestedSchema: { + type: "object", + properties: { + value: { + type: "string", + title: "Decision", + enum: ["Approve", "Deny"], + }, + }, + required: ["value"], + }, + }); + const action = + typeof result === "object" && result !== null && "action" in result + ? result.action + : undefined; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `elicitation ${typeof action === "string" ? action : "unknown"}`, + }, + }, + }); + return { stopReason: "end_turn" }; + } + + if (emitTaskTool || emitTaskToolBatch || emitTaskToolFail) { + const toolCallId = "task-tool-call-1"; + const rawInput = emitTaskToolBatch + ? { + tasks: [ + { agent: "scout", task: "Research the codebase layout", effort: "low" }, + { agent: "worker", task: "Implement the feature", effort: "high" }, + ], + context: "shared batch context", + } + : { agent: "worker", task: "Implement the feature", effort: "high" }; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Task", + kind: "other", + status: "pending", + rawInput, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: emitTaskToolFail ? "failed" : "completed", + rawOutput: { + output: emitTaskToolFail ? "subagent failed to finish" : "subagent finished the work", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "task tool done" }, + }, + }); + + return { stopReason: "end_turn" }; + } + if (emitToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts new file mode 100644 index 000000000000..8d5fa71e8236 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -0,0 +1,173 @@ +/** + * OmpDriver — `ProviderDriver` for the Oh My Pi (`omp`) runtime. + * + * Oh My Pi exposes an ACP-based CLI (`omp acp`). Like OpenCode it is a meta + * provider: the model catalog is whatever the user configured inside omp and + * is discovered dynamically from the ACP `model` config option during the + * managed provider status check — nothing is hardcoded. + * + * Text generation is supported via the ACP runtime — `makeOmpTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/OmpDriver + */ +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 { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeOmpSettings = Schema.decodeSync(OmpSettings); + +const DRIVER_KIND = ProviderDriverKind.make("omp"); +// Manual-only maintenance: omp ships outside the registries T3 already knows +// how to update, so T3 never guesses an update command for it. +const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +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 crypto = yield* Crypto.Crypto; + 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 adapter = yield* makeOmpAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOmpProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialOmpProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + // Model catalog and capabilities come exclusively from the probe ACP + // session's configOptions during provider checks. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichOmpSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities: MAINTENANCE_CAPABILITIES, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + stampIdentity, + 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/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts new file mode 100644 index 000000000000..97306a09af73 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -0,0 +1,2473 @@ +// This suite builds real mock-agent wrapper scripts and temp directories on +// disk, so direct node: imports are intentional. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +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 Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { + ApprovalRequestId, + OmpSettings, + ProviderDriverKind, + type ProviderRuntimeEvent, + ThreadId, + TurnId, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { describe, expect, it as plainIt } from "vite-plus/test"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { OmpAdapterShape } from "../Services/OmpAdapter.ts"; +import { + makeOmpAdapter, + ompElicitationContentFromAnswers, + ompElicitationQuestionsFromForm, + parseOmpSubagentSpawns, + selectOmpPermissionOptionId, +} from "./OmpAdapter.ts"; +const decodeOmpSettings = Schema.decodeSync(OmpSettings); + +// Test-local service tag so the rest of the file can keep using `yield* OmpAdapter`. +class OmpAdapter extends Context.Service()( + "t3/provider/Layers/OmpAdapter.test/OmpAdapter", +) {} + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = "node"; +const mockAgentArgs = [mockAgentPath] as const; + +async function makeMockAgentWrapper( + extraEnv?: Record, + options?: { initialDelaySeconds?: number }, +) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-omp.sh"); + const envExports = Object.entries({ T3_ACP_OMP_SHAPES: "1", ...extraEnv }) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} +exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function makeProbeWrapper( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-probe-")); + const wrapperPath = NodePath.join(dir, "fake-omp.sh"); + const envExports = Object.entries({ T3_ACP_OMP_SHAPES: "1", ...extraEnv }) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} +printf '\n' >> ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${mockAgentArgs.map((arg) => JSON.stringify(arg)).join(" ")} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readArgvLog(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.split("\t").filter((token) => token.length > 0)); +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +async function waitForFileContent(filePath: string, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const raw = await NodeFSP.readFile(filePath, "utf8"); + if (raw.trim().length > 0) { + return raw; + } + } catch {} + // Microtask yield, matching the previous Effect.yieldNow pacing: each loop + // iteration already awaits real fs I/O, so no wall-clock timer is needed. + await Promise.resolve(); + } + throw new Error(`Timed out waiting for file content at ${filePath}`); +} + +function waitForJsonLogMatch( + filePath: string, + predicate: (entry: Record) => boolean, + attempts = 40, +) { + return Effect.gen(function* () { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const requests = yield* Effect.promise(() => readJsonLines(filePath)); + if (requests.some(predicate)) { + return requests; + } + yield* Effect.yieldNow; + } + return yield* Effect.promise(() => readJsonLines(filePath)); + }); +} + +// Tests mutate `ServerSettingsService` mid-flight (e.g. setting +// `providers.omp.binaryPath` to a mock ACP wrapper). The adapter +// captures `ompSettings` once at construction, so without a resolver +// the mutation is invisible — sessions would spawn the constructor's +// (empty) binary path. Wiring `resolveSettings` through +// `ServerSettingsService.getSettings` makes each session read the latest +// snapshot, matching the old "always read live" behavior that these +// tests assumed. +const makeResolveOmpSettings = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + return yield* Effect.succeed( + serverSettings.getSettings.pipe( + Effect.map((snapshot) => snapshot.providers.omp), + Effect.orDie, + ), + ); +}); + +const makeOmpAdapterTestLayer = (instanceId?: ProviderInstanceId) => + Layer.effect( + OmpAdapter, + Effect.gen(function* () { + const ompConfig = decodeOmpSettings({}); + const resolveSettings = yield* makeResolveOmpSettings; + return yield* makeOmpAdapter(ompConfig, { + ...(instanceId ? { instanceId } : {}), + resolveSettings, + }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-omp-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + +const elicitationFormRequest = { + mode: "form", + sessionId: "mock-session-1", + message: "Approve this action?", + requestedSchema: { + type: "object", + properties: { + decision: { + type: "string", + title: "Decision", + description: "Pick one", + enum: ["Approve", "Deny"], + }, + confirm: { type: "boolean", title: "Confirm" }, + notes: { type: "string" }, + tags: { type: "array", items: { type: "string", enum: ["a", "b"] } }, + }, + required: ["decision"], + }, +} as const; + +describe("ompElicitationQuestionsFromForm", () => { + plainIt("maps a string enum property onto a select question", () => { + const questions = ompElicitationQuestionsFromForm(elicitationFormRequest); + expect(questions[0]).toEqual({ + id: "decision", + header: "Decision", + question: "Pick one", + multiSelect: false, + options: [ + { label: "Approve", description: "Approve" }, + { label: "Deny", description: "Deny" }, + ], + }); + }); + + plainIt("maps a boolean property onto True/False options", () => { + const questions = ompElicitationQuestionsFromForm(elicitationFormRequest); + expect(questions[1]).toEqual({ + id: "confirm", + header: "Confirm", + question: "Approve this action?", + multiSelect: false, + options: [ + { label: "True", description: "Yes" }, + { label: "False", description: "No" }, + ], + }); + }); + + plainIt("falls back to free text for properties without enums", () => { + const questions = ompElicitationQuestionsFromForm(elicitationFormRequest); + expect(questions[2]).toMatchObject({ id: "notes", header: "notes", options: [] }); + expect(questions[3]).toMatchObject({ id: "tags", header: "tags", options: [] }); + }); + + plainIt("uses a fallback question when the message is empty", () => { + const questions = ompElicitationQuestionsFromForm({ + mode: "form", + sessionId: "mock-session-1", + message: " ", + requestedSchema: { type: "object", properties: { value: { type: "string" } } }, + }); + expect(questions[0]?.question).toBe("Oh My Pi requests input."); + }); +}); + +describe("ompElicitationContentFromAnswers", () => { + plainIt("maps option labels and booleans back to content values", () => { + expect( + ompElicitationContentFromAnswers(elicitationFormRequest, { + decision: "Deny", + confirm: "False", + notes: "looks risky", + tags: ["a", "b"], + }), + ).toEqual({ + decision: "Deny", + confirm: false, + notes: "looks risky", + tags: ["a", "b"], + }); + }); + + plainIt("omits unanswered and empty answers", () => { + expect( + ompElicitationContentFromAnswers(elicitationFormRequest, { + decision: " ", + notes: "ok", + }), + ).toEqual({ notes: "ok" }); + expect(ompElicitationContentFromAnswers(elicitationFormRequest, {})).toEqual({}); + }); +}); + +describe("parseOmpSubagentSpawns", () => { + plainIt("parses a single task tool call", () => { + expect( + parseOmpSubagentSpawns("tool-1", { + agent: "worker", + task: "Implement the feature", + effort: "high", + }), + ).toEqual([ + { taskId: "tool-1", title: "Implement the feature", role: "worker", effort: "high" }, + ]); + }); + + plainIt("parses a batch task tool call into one spawn per item", () => { + expect( + parseOmpSubagentSpawns("tool-1", { + tasks: [ + { agent: "scout", task: "Research the codebase layout", effort: "low" }, + { agent: "worker", task: "Implement the feature" }, + ], + context: "shared batch context", + }), + ).toEqual([ + { taskId: "tool-1:0", title: "Research the codebase layout", role: "scout", effort: "low" }, + { taskId: "tool-1:1", title: "Implement the feature", role: "worker" }, + ]); + }); + + plainIt("rejects inputs with keys outside the omp task schema", () => { + expect(parseOmpSubagentSpawns("tool-1", { task: "x", url: "https://example.com" })).toEqual([]); + expect(parseOmpSubagentSpawns("tool-1", { tasks: [{ task: "x" }], command: ["ls"] })).toEqual( + [], + ); + }); + + plainIt("rejects non-task tools and empty task payloads", () => { + expect(parseOmpSubagentSpawns("tool-1", { command: ["ls"] })).toEqual([]); + expect(parseOmpSubagentSpawns("tool-1", { task: " " })).toEqual([]); + expect(parseOmpSubagentSpawns("tool-1", { tasks: [{ name: "no task field" }] })).toEqual([]); + expect(parseOmpSubagentSpawns("tool-1", "not an object")).toEqual([]); + }); +}); + +describe("selectOmpPermissionOptionId", () => { + const request = ( + options: ReadonlyArray<{ kind: string; optionId: string }>, + ): Parameters[0] => ({ options }) as never; + + plainIt("matches the decision kind against the advertised option id", () => { + const req = request([ + { kind: "allow_once", optionId: "allow_once" }, + { kind: "allow_always", optionId: "allow_always" }, + { kind: "reject_once", optionId: "reject_once" }, + ]); + expect(selectOmpPermissionOptionId(req, "accept")).toBe("allow_once"); + expect(selectOmpPermissionOptionId(req, "acceptForSession")).toBe("allow_always"); + expect(selectOmpPermissionOptionId(req, "decline")).toBe("reject_once"); + }); + + plainIt("falls back to allow_once when the agent offers no allow_always", () => { + const req = request([{ kind: "allow_once", optionId: "yes-once" }]); + expect(selectOmpPermissionOptionId(req, "acceptForSession")).toBe("yes-once"); + }); + + plainIt("skips options with blank ids and prefers reject_once over reject_always", () => { + const req = request([ + { kind: "reject_once", optionId: " " }, + { kind: "reject_always", optionId: "never" }, + ]); + expect(selectOmpPermissionOptionId(req, "decline")).toBe("never"); + }); + + plainIt("returns undefined when nothing usable was offered", () => { + const req = request([{ kind: "allow_once", optionId: "ok" }]); + expect(selectOmpPermissionOptionId(req, "decline")).toBeUndefined(); + }); +}); + +const ompAdapterTestLayer = it.layer(makeOmpAdapterTestLayer()); + +ompAdapterTestLayer("OmpAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-mock-thread"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(9), + 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: "openai/gpt-5.4" }, + }); + + assert.equal(session.provider, "omp"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((e) => e.type); + + for (const t 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(types, t); + } + + const assistantStarted = runtimeEvents.find( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantStarted); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + assert.match(String(delta.itemId), /^assistant:mock-session-1:runtime:[^:]+:segment:0$/); + } + + const assistantCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantCompleted); + + const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.isDefined(planUpdate); + if (planUpdate?.type === "turn.plan.updated") { + assert.deepStrictEqual(planUpdate.payload.plan, [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-steer-thread"); + + // Keep the first prompt in flight long enough for the steer to land. + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "1500" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const firstTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run 5 commands", + attachments: [], + }) + .pipe(Effect.forkChild); + + // Poll until the first prompt is in flight — sendTurn binds the active + // turn id before prompting. The mock agent runs on the real clock, so + // each TestClock.adjust just provides the scheduler hops for its stdio + // responses to land. + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt += 1) { + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + if (session?.activeTurnId !== undefined) { + return; + } + yield* TestClock.adjust("10 millis"); + } + throw new Error("Timed out waiting for the first prompt to be in flight."); + }); + + // Steer: a second sendTurn while the first prompt is still in flight + // continues the same turn. + const steeredTurn = yield* adapter.sendTurn({ + threadId, + input: "actually run 15", + attachments: [], + }); + const firstTurn = yield* Fiber.join(firstTurnFiber); + assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); + const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed"); + + // One turn boundary for the whole run: the superseded first prompt + // resolving must not settle the merged turn. + assert.equal(turnStartedEvents.length, 1); + assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId)); + assert.equal(turnCompletedEvents.length, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId)); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.include(exitLog, "SIGTERM"); + }), + ); + + it.effect( + "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", + () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-concurrent-start-session"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-adapter-concurrent-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }, + { initialDelaySeconds: 0.2 }, + ), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const [firstSession, secondSession] = yield* Effect.all( + [ + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "openai/gpt-5.4", + }, + }), + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "openai/gpt-5.4", + }, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(firstSession.threadId, threadId); + assert.equal(secondSession.threadId, threadId); + + yield* adapter.stopSession(threadId); + + // ChildProcess.kill does not wait for the child to exit, so poll + // until both signal handlers have appended their SIGTERM entry. + let exitLog = ""; + for (let attempt = 0; attempt < 400; attempt += 1) { + exitLog = yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")); + if ((exitLog.match(/SIGTERM/g)?.length ?? 0) >= 2) { + break; + } + // This test runs under a frozen TestClock, so poll with a + // clock-independent yield: each iteration already awaits real fs + // I/O, which is what actually advances the child's exit writes. + yield* Effect.yieldNow; + } + assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const result = yield* adapter + .startSession({ + threadId: ThreadId.make("bad-provider"), + provider: ProviderDriverKind.make("codex"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("maps app plan mode onto the ACP plan session mode", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-plan-mode-probe"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequest = requests + .toReversed() + .find( + (entry) => + entry.method === "session/set_mode" || + (entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "mode"), + ); + assert.isDefined(modeRequest); + assert.equal( + (modeRequest?.params as Record | undefined)?.sessionId, + "mock-session-1", + ); + assert.equal( + String( + (modeRequest?.params as Record | undefined)?.modeId ?? + (modeRequest?.params as Record | undefined)?.value, + ), + "plan", + ); + }), + ); + + it.effect( + "applies initial model and mode configuration during startSession and skips repeating it on first send", + () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-initial-config-probe"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath } }, + }); + + const modelSelection = createModelSelection( + ProviderInstanceId.make("omp"), + "openai/gpt-5.4", + [{ id: "reasoning", value: "max" }], + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection, + }); + + yield* Effect.promise(() => waitForFileContent(requestLogPath)); + + // The session spawns in the mock's `default` mode already, so only the + // model and thinking config options are written. + const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const configIdsAfterStart = requestsAfterStart.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(configIdsAfterStart, ["model", "thinking"]); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + modelSelection, + interactionMode: "default", + }); + yield* adapter.stopSession(threadId); + + const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const finalConfigIds = finalRequests.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(finalConfigIds, ["model", "thinking"]); + assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1); + }), + ); + + it.effect( + "streams ACP tool calls and approvals on the active turn in approval-required mode", + () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath } }, + }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ); + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + yield* Deferred.await(settledEventsReady); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.includeMembers( + threadEvents.map((event) => event.type), + [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "item.updated", + "item.completed", + "content.delta", + "turn.completed", + ], + ); + + const turnEvents = threadEvents.filter( + (event) => String(event.turnId) === String(turn.turnId), + ); + const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); + // ACP updates can arrive either as distinct pending + in-progress events + // or as a single coalesced in-progress update before approval resolves. + assert.isAtLeast(toolUpdates.length, 1); + for (const toolUpdate of toolUpdates) { + if (toolUpdate.type !== "item.updated") { + continue; + } + assert.equal(toolUpdate.payload.itemType, "command_execution"); + assert.equal(toolUpdate.payload.status, "inProgress"); + assert.equal(toolUpdate.payload.detail, "cat server/package.json"); + assert.equal(String(toolUpdate.itemId), "tool-call-1"); + } + + const requestOpened = turnEvents.find((event) => event.type === "request.opened"); + assert.isDefined(requestOpened); + if (requestOpened?.type === "request.opened") { + assert.equal(String(requestOpened.turnId), String(turn.turnId)); + assert.equal(requestOpened.payload.requestType, "exec_command_approval"); + assert.equal(requestOpened.payload.detail, "cat server/package.json"); + } + + const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); + assert.isDefined(requestResolved); + if (requestResolved?.type === "request.resolved") { + assert.equal(String(requestResolved.turnId), String(turn.turnId)); + assert.equal(requestResolved.payload.requestType, "exec_command_approval"); + assert.equal(requestResolved.payload.decision, "accept"); + } + + const toolCompleted = turnEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + assert.equal(toolCompleted.payload.itemType, "command_execution"); + assert.equal(toolCompleted.payload.status, "completed"); + assert.equal(toolCompleted.payload.detail, "cat server/package.json"); + assert.equal(String(toolCompleted.itemId), "tool-call-1"); + } + + const contentDelta = turnEvents.find((event) => event.type === "content.delta"); + assert.isDefined(contentDelta); + if (contentDelta?.type === "content.delta") { + assert.equal(String(contentDelta.turnId), String(turn.turnId)); + assert.equal(contentDelta.payload.delta, "hello from mock"); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:runtime:[^:]+:segment:0$/, + ); + } + }), + ); + + it.effect( + "auto-approves ACP tool permissions in full-access mode without approval runtime events", + () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-full-access-auto-approve"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath } }, + }); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); + + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.opened", + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.resolved", + ); + assert.includeMembers( + turnEvents.map((event) => event.type), + ["item.updated", "item.completed", "content.delta", "turn.completed"], + ); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResponse = requests.find( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "selected" && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-always", + ); + assert.isDefined(permissionResponse); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("segments assistant messages around ACP tool activity in full-access mode", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-assistant-tool-segmentation"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath } }, + }); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "content.delta" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "turn.completed" + ) { + if (event.type === "content.delta") { + settledEventTypes.add(`delta:${event.payload.delta}`); + } else { + settledEventTypes.add(event.type); + } + if ( + settledEventTypes.has("delta:before tool") && + settledEventTypes.has("delta:after tool") && + settledEventTypes.has("item.completed") && + settledEventTypes.has("turn.completed") + ) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run an interleaved tool call", + attachments: [], + }); + + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); + + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), + ); + const firstAssistantStartIndex = turnEvents.findIndex( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const firstAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "before tool", + ); + const assistantBoundaryIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + const toolUpdateIndex = turnEvents.findIndex( + (event) => event.type === "item.updated" && event.payload.itemType === "command_execution", + ); + const toolCompletedIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + const secondAssistantStartIndex = turnEvents.findIndex( + (event, index) => + index > toolCompletedIndex && + event.type === "item.started" && + event.payload.itemType === "assistant_message", + ); + const secondAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "after tool", + ); + + assert.isAtLeast(firstAssistantStartIndex, 0); + assert.isAtLeast(firstAssistantDeltaIndex, 0); + assert.isAtLeast(assistantBoundaryIndex, 0); + assert.isAtLeast(toolUpdateIndex, 0); + assert.isAtLeast(toolCompletedIndex, 0); + assert.isAtLeast(secondAssistantStartIndex, 0); + assert.isAtLeast(secondAssistantDeltaIndex, 0); + assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex); + assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex); + assert.isBelow(assistantBoundaryIndex, toolUpdateIndex); + assert.isBelow(toolUpdateIndex, toolCompletedIndex); + assert.isBelow(toolCompletedIndex, secondAssistantStartIndex); + assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex); + + const assistantStarts = turnEvents.filter( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta"); + assert.lengthOf(assistantStarts, 2); + assert.lengthOf(assistantDeltas, 2); + if ( + assistantStarts[0]?.type === "item.started" && + assistantStarts[1]?.type === "item.started" && + assistantDeltas[0]?.type === "content.delta" && + assistantDeltas[1]?.type === "content.delta" + ) { + assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId)); + assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId)); + assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId)); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-cancel-probe"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const requestResolvedReady = yield* Deferred.make(); + const turnCompletedReady = yield* Deferred.make(); + let interrupted = false; + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId && !interrupted) { + interrupted = true; + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "cancel", + ); + yield* adapter.interruptTurn(threadId); + return; + } + if (event.type === "request.resolved") { + yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel this turn", + attachments: [], + }) + .pipe(Effect.forkChild); + + const requestResolved = yield* Deferred.await(requestResolvedReady); + const turnCompleted = yield* Deferred.await(turnCompletedReady); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(runtimeEventsFiber); + + assert.equal(requestResolved.type, "request.resolved"); + if (requestResolved.type === "request.resolved") { + assert.equal(requestResolved.payload.decision, "cancel"); + } + + assert.equal(turnCompleted.type, "turn.completed"); + if (turnCompleted.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "cancelled"); + assert.equal(turnCompleted.payload.stopReason, "cancelled"); + } + + const isCancelledApprovalResponse = (entry: Record) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "cancelled"; + const approvalResponses = yield* waitForJsonLogMatch( + requestLogPath, + isCancelledApprovalResponse, + ); + assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stopping a session settles pending approval waits", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-stop-pending-approval"); + const approvalRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { + return Effect.void; + } + return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run a tool call and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(approvalRequested); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("bridges omp form elicitations into user-input requests", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-elicitation-probe"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_ELICITATION: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEvents: Array = []; + const userInputResolved = yield* Deferred.make(); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "user-input.requested" && event.requestId) { + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(event.requestId)), + { value: "Deny" }, + ); + } + if (event.type === "user-input.resolved") { + yield* Deferred.succeed(userInputResolved, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "do something that needs approval", + attachments: [], + }); + yield* Deferred.await(userInputResolved); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const requested = threadEvents.find((event) => event.type === "user-input.requested"); + assert.isDefined(requested); + if (requested?.type === "user-input.requested") { + assert.deepStrictEqual(requested.payload.questions, [ + { + id: "value", + header: "Decision", + question: "Approve this action?", + multiSelect: false, + options: [ + { label: "Approve", description: "Approve" }, + { label: "Deny", description: "Deny" }, + ], + }, + ]); + } + + const resolved = threadEvents.find((event) => event.type === "user-input.resolved"); + assert.isDefined(resolved); + if (resolved?.type === "user-input.resolved") { + assert.deepStrictEqual(resolved.payload.answers, { value: "Deny" }); + } + + // The mock agent logs every incoming JSON-RPC payload, so the client's + // elicitation response shows up there. omp's official ACP SDK reads a + // FLAT response ({ action: "accept", content }) — assert exactly that. + const isElicitationResponse = (entry: Record) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "action" in entry.result && + entry.result.action === "accept"; + const responses = yield* waitForJsonLogMatch(requestLogPath, isElicitationResponse); + const elicitationResponse = responses.find(isElicitationResponse); + assert.isDefined(elicitationResponse); + const elicitationResult = elicitationResponse?.result as Record; + assert.deepStrictEqual(elicitationResult.content, { value: "Deny" }); + + const delta = threadEvents.find( + (event) => event.type === "content.delta" && event.payload.delta === "elicitation accept", + ); + assert.isDefined(delta); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles the turn as failed when the prompt errors after turn.started", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-prompt-failure"); + const runtimeEvents: Array = []; + const turnSettled = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_FAIL_PROMPT: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) === String(threadId) && event.type === "turn.completed") { + yield* Deferred.succeed(turnSettled, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const sendExit = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "this prompt fails", + attachments: [], + }), + ); + assert.isTrue(Exit.isFailure(sendExit), "sendTurn should still propagate the prompt error"); + yield* Deferred.await(turnSettled); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const turnStartedIndex = threadEvents.findIndex((event) => event.type === "turn.started"); + const turnCompletedIndex = threadEvents.findIndex((event) => event.type === "turn.completed"); + assert.isAtLeast(turnStartedIndex, 0); + assert.isAbove(turnCompletedIndex, turnStartedIndex); + + const turnCompleted = threadEvents[turnCompletedIndex]; + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "failed"); + assert.isString(turnCompleted.payload.errorMessage); + assert.isNotEmpty(turnCompleted.payload.errorMessage); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancelling during sendTurn preparation prevents the prompt from being sent", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-prepare-cancel"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + // Slow set_config_option responses keep the prepare phase busy long + // enough for interruptTurn to land before the prompt goes out. + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "500", + }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEvents: Array = []; + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel me before the prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-opus-4-6", + }, + }) + .pipe(Effect.forkChild); + + // Wait until sendTurn's config write is in flight: the mock logs the + // request on receipt, then delays its response by 500ms (real time), + // so interruptTurn is guaranteed to land inside the prepare phase. + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt += 1) { + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + if (requests.some((entry) => entry.method === "session/set_config_option")) { + return; + } + yield* Effect.sleep("25 millis"); + } + throw new Error("Timed out waiting for the config write to be in flight."); + }); + + yield* adapter.interruptTurn(threadId); + // Cancellation during preparation resolves sendTurn normally instead of + // surfacing an error. + yield* Fiber.join(sendTurnFiber); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const turnCompleted = threadEvents.find((event) => event.type === "turn.completed"); + assert.isDefined(turnCompleted); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "cancelled"); + } + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal( + requests.filter((entry) => entry.method === "session/prompt").length, + 0, + "cancelled-before-prompt turn must never reach session/prompt", + ); + + yield* adapter.stopSession(threadId); + // Live clock so the polling above advances against the mock's real-time + // set_config_option delay. + }).pipe(TestClock.withLive), + ); + + it.effect("ignores interrupt requests for turns that are no longer active", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-stale-interrupt"); + const runtimeEvents: Array = []; + + // Keep the prompt in flight long enough for the stale interrupt to land. + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "1500" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "keep running", attachments: [] }) + .pipe(Effect.forkChild); + + // Wait until the turn is active, then interrupt with a turn id that + // does not match — a late cancel for a long-finished turn. + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt += 1) { + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + if (session?.activeTurnId !== undefined) { + return; + } + yield* TestClock.adjust("10 millis"); + } + throw new Error("Timed out waiting for the turn to become active."); + }); + + yield* adapter.interruptTurn(threadId, TurnId.make("omp-stale-turn-id")); + yield* Fiber.join(sendTurnFiber); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const turnCompleted = threadEvents.find((event) => event.type === "turn.completed"); + assert.isDefined(turnCompleted); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "completed"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "cancels a turn during preparation before dispatch and releases the dispatch lock", + () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-prepare-cancel-serialized"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + // A slow set_config_option keeps the turn inside the dispatch permit + // (configuration write) so the interrupt lands before dispatch. + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "500", + }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEvents: Array = []; + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const turnFiber = yield* adapter + .sendTurn({ + threadId, + input: "first prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-opus-4-6", + }, + }) + .pipe(Effect.forkChild); + + for (let attempt = 0; attempt < 200; attempt += 1) { + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + if (requests.some((entry) => entry.method === "session/set_config_option")) { + break; + } + yield* Effect.sleep("25 millis"); + } + yield* adapter.interruptTurn(threadId); + + const turn = yield* Fiber.join(turnFiber); + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const turnCompletedEvents = threadEvents.filter((event) => event.type === "turn.completed"); + assert.lengthOf(turnCompletedEvents, 1, "cancelled turn settles exactly once"); + if (turnCompletedEvents[0]?.type === "turn.completed") { + assert.equal(turnCompletedEvents[0].payload.state, "cancelled"); + assert.equal(String(turnCompletedEvents[0].turnId), String(turn.turnId)); + } + assert.equal( + threadEvents.some((event) => event.type === "turn.started"), + false, + "a turn cancelled during preparation never stamps turn.started", + ); + + const preCancelRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal( + preCancelRequests.filter((entry) => entry.method === "session/prompt").length, + 0, + "cancelled-during-prepare turn must never reach session/prompt", + ); + + // The dispatch permit must be released even though the first turn + // died mid-configuration: a follow-up turn still dispatches. + const second = yield* adapter.sendTurn({ + threadId, + input: "second prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-opus-4-6", + }, + }); + assert.ok(second.turnId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isAtLeast( + requests.filter((entry) => entry.method === "session/prompt").length, + 1, + "the follow-up turn reaches session/prompt", + ); + + yield* adapter.stopSession(threadId); + // Live clock so the polling above advances against the mock's + // real-time set_config_option delay. + }).pipe(TestClock.withLive), + ); + + it.effect("serializes each turn's configuration write with its prompt dispatch", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-dispatch-serialization"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + // Slow configuration writes widen the window in which a concurrent + // sendTurn could interleave its own model write before the first + // turn's prompt dispatches. + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "300", + }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const first = yield* adapter + .sendTurn({ + threadId, + input: "first prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-opus-4-6", + }, + }) + .pipe(Effect.forkChild); + const second = yield* adapter + .sendTurn({ + threadId, + input: "second prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "openai/gpt-5.4", + }, + }) + .pipe(Effect.forkChild); + + yield* Fiber.join(first); + yield* Fiber.join(second); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const order = requests + .filter( + (entry) => + entry.method === "session/set_config_option" || entry.method === "session/prompt", + ) + .map((entry) => { + if (entry.method === "session/prompt") { + return "prompt"; + } + const params = entry.params; + if (params && typeof params === "object" && "configId" in params) { + return String(params.configId ?? ""); + } + return ""; + }); + // The dispatch lock must keep each turn's model write adjacent to its + // own dispatch: set(model A) -> prompt -> set(model B) -> prompt. An + // interleaving such as set(A) -> set(B) -> prompt would mean a turn + // ran under another turn's model. + const modelWrites = order.filter((entry) => entry !== "prompt"); + const prompts = order.filter((entry) => entry === "prompt").length; + assert.isAtLeast(modelWrites.length, 2, "both turns wrote their model"); + assert.equal(prompts, 2, "both turns dispatched"); + for (let index = 0; index < order.length; index += 1) { + if (order[index] === "prompt") { + continue; + } + if (index + 1 < order.length) { + assert.equal( + order[index + 1], + "prompt", + `model write at ${index} must be immediately followed by its own dispatch`, + ); + } + } + + yield* adapter.stopSession(threadId); + // Live clock so the mock's real-time configuration delay applies. + }).pipe(TestClock.withLive), + ); + + it.effect("answers permission requests with the option ids the agent advertised", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-permission-option-ids"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + // omp advertises snake_case option ids; the adapter must echo the + // advertised id, not a hardcoded hyphenated one. + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_ALLOW_ONCE_OPTION_ID: "allow_once", + T3_ACP_ALLOW_ALWAYS_OPTION_ID: "allow_always", + T3_ACP_REJECT_ONCE_OPTION_ID: "reject_once", + }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + let openedCount = 0; + const turnSettled = yield* Deferred.make(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + openedCount += 1; + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + openedCount === 1 ? "accept" : "acceptForSession", + ); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnSettled, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "run two tool calls", + attachments: [], + }); + yield* Deferred.await(turnSettled); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const selectedOptionIds = requests.flatMap((entry) => { + if ("method" in entry) { + return []; + } + const result = entry.result as + | { outcome?: { outcome?: string; optionId?: unknown } } + | undefined; + return result?.outcome?.outcome === "selected" && + typeof result.outcome.optionId === "string" + ? [result.outcome.optionId] + : []; + }); + assert.deepStrictEqual(selectedOptionIds, ["allow_once", "allow_always"]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not settle a cancelled turn after the session was stopped", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-cancel-after-stop"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "500", + }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEvents: Array = []; + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel me, then stop the session", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-opus-4-6", + }, + }) + .pipe(Effect.forkChild); + + // Wait for the config write to be in flight, then interrupt (marks the + // turn) and stop the session before the write resolves. Stopping kills + // the ACP child, so the in-flight request fails — both the failure and + // the deferred cancel settle must stay silent on the dead session. + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt += 1) { + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + if (requests.some((entry) => entry.method === "session/set_config_option")) { + return; + } + yield* Effect.sleep("25 millis"); + } + throw new Error("Timed out waiting for the config write to be in flight."); + }); + + yield* adapter.interruptTurn(threadId); + yield* adapter.stopSession(threadId); + // The sendTurn fiber fails with the transport error from the killed + // child; that propagation is intentional. + const sendExit = yield* Fiber.await(sendTurnFiber); + assert.isTrue(Exit.isFailure(sendExit)); + // Let the PubSub consumer drain before reading the collected events. + yield* Effect.sleep("50 millis"); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.isTrue( + threadEvents.some((event) => event.type === "session.exited"), + "session.exited should be emitted", + ); + assert.equal( + threadEvents.filter((event) => event.type === "turn.completed").length, + 0, + "no turn.completed may be published for a turn whose session was stopped", + ); + // Live clock so the polling above advances against the mock's + // real-time set_config_option delay. + }).pipe(TestClock.withLive), + ); + + it.effect("broadcasts runtime events to multiple stream consumers", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-runtime-event-broadcast"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + const firstConsumer = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + const secondConsumer = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); + const secondEvents = Array.from(yield* Fiber.join(secondConsumer)); + + assert.deepStrictEqual( + firstEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + assert.deepStrictEqual( + secondEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("switches model in-session via session/set_config_option without respawning", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-model-switch"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after switching model", + attachments: [], + modelSelection: createModelSelection( + ProviderInstanceId.make("omp"), + "anthropic/claude-opus-4-6", + [{ id: "reasoning", value: "low" }], + ), + }); + + // full-access is a spawn-time approval flag for omp; switching models + // must not restart the session. + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); + assert.deepStrictEqual(argvRuns[0], ["acp", "--approval-mode=yolo"]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modelConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "model", + ); + assert.isAbove(modelConfigRequests.length, 1, "should set the model per turn"); + const lastModelConfig = modelConfigRequests[modelConfigRequests.length - 1]; + assert.equal( + (lastModelConfig?.params as Record)?.value, + "anthropic/claude-opus-4-6", + ); + + const thinkingConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "thinking", + ); + assert.isAbove(thinkingConfigRequests.length, 0, "should apply reasoning as thinking"); + const lastThinkingConfig = thinkingConfigRequests[thinkingConfigRequests.length - 1]; + assert.equal((lastThinkingConfig?.params as Record)?.value, "low"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("clears prior thinking in-session when the next turn lowers reasoning", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-thinking-reset"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with max thinking", + attachments: [], + modelSelection: createModelSelection(ProviderInstanceId.make("omp"), "openai/gpt-5.4", [ + { id: "reasoning", value: "max" }, + ]), + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn with low thinking", + attachments: [], + modelSelection: createModelSelection(ProviderInstanceId.make("omp"), "openai/gpt-5.4", [ + { id: "reasoning", value: "low" }, + ]), + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const thinkingConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "thinking", + ); + assert.isAtLeast(thinkingConfigRequests.length, 2, "should set thinking up and then down"); + + const lastThinkingConfig = thinkingConfigRequests[thinkingConfigRequests.length - 1]; + assert.equal((lastThinkingConfig?.params as Record)?.value, "low"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "applies reasoning on the first turn when modelSelection uses a non-default instance id", + () => { + const customInstanceId = ProviderInstanceId.make("omp_secondary"); + // Custom-instance cases can't share the suite-level `OmpAdapter` + // layer because that one binds `instanceId: "omp"`. We build a + // fresh layer graph — including a fresh `ServerSettingsService` — so + // mid-test `updateSettings` calls target the same service instance the + // adapter's `resolveSettings` reads from, and so the outer + // `yield* ServerSettingsService` sees the same snapshot as well. + const customAdapterLayer = makeOmpAdapterTestLayer(customInstanceId); + + return Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-reasoning-custom-instance"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const argvLogPath = NodePath.join(tempDir, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { omp: { binaryPath: wrapperPath } }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: customInstanceId, + model: "openai/gpt-5.4", + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with max thinking", + attachments: [], + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("omp"), "openai/gpt-5.4", [ + { id: "reasoning", value: "max" }, + ]), + instanceId: customInstanceId, + }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const thinkingConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "thinking", + ); + assert.isAbove( + thinkingConfigRequests.length, + 0, + "reasoning should apply when instance id matches the adapter binding", + ); + const lastThinkingConfig = thinkingConfigRequests[thinkingConfigRequests.length - 1]; + assert.equal((lastThinkingConfig?.params as Record)?.value, "max"); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(customAdapterLayer)); + }, + ); + + it.effect("projects a single omp task tool call into Agents-panel task events", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-task-tool-single"); + const runtimeEvents: Array = []; + const taskCompleted = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TASK_TOOL: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) === String(threadId) && event.type === "task.completed") { + yield* Deferred.succeed(taskCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "spawn a subagent", + attachments: [], + }); + yield* Deferred.await(taskCompleted); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const taskStarted = threadEvents.find((event) => event.type === "task.started"); + assert.isDefined(taskStarted); + if (taskStarted?.type === "task.started") { + assert.equal(String(taskStarted.payload.taskId), "task-tool-call-1"); + assert.equal(taskStarted.payload.taskType, "subagent"); + assert.equal(taskStarted.payload.title, "Implement the feature"); + assert.equal(taskStarted.payload.role, "worker"); + assert.equal(taskStarted.payload.effort, "high"); + assert.equal(taskStarted.payload.toolUseId, "task-tool-call-1"); + } + + const completed = threadEvents.find((event) => event.type === "task.completed"); + assert.isDefined(completed); + if (completed?.type === "task.completed") { + assert.equal(String(completed.payload.taskId), "task-tool-call-1"); + assert.equal(completed.payload.status, "completed"); + assert.equal(completed.payload.summary, "subagent finished the work"); + assert.equal(completed.payload.taskType, "subagent"); + assert.equal(completed.payload.role, "worker"); + assert.equal(completed.payload.toolUseId, "task-tool-call-1"); + } + + // The task tool call still shows up as an ordinary tool row in the + // timeline, mirroring how Claude's Task tool is displayed. + assert.isTrue( + threadEvents.some( + (event) => + (event.type === "item.updated" || event.type === "item.completed") && + String(event.itemId) === "task-tool-call-1", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("projects a batch omp task tool call into one task per item", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-task-tool-batch"); + const runtimeEvents: Array = []; + const allTasksCompleted = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TASK_TOOL_BATCH: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + const completedCount = runtimeEvents.filter( + (entry) => + String(entry.threadId) === String(threadId) && entry.type === "task.completed", + ).length; + if (completedCount >= 2) { + yield* Deferred.succeed(allTasksCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "spawn two subagents", + attachments: [], + }); + yield* Deferred.await(allTasksCompleted); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + const starts = threadEvents.filter((event) => event.type === "task.started"); + assert.lengthOf(starts, 2); + const startsByTaskId = new Map( + starts.flatMap((event) => + event.type === "task.started" + ? [[String(event.payload.taskId), event.payload] as const] + : [], + ), + ); + assert.deepInclude(startsByTaskId.get("task-tool-call-1:0"), { + taskType: "subagent", + title: "Research the codebase layout", + role: "scout", + effort: "low", + toolUseId: "task-tool-call-1", + }); + assert.deepInclude(startsByTaskId.get("task-tool-call-1:1"), { + taskType: "subagent", + title: "Implement the feature", + role: "worker", + effort: "high", + toolUseId: "task-tool-call-1", + }); + + const completions = threadEvents.filter((event) => event.type === "task.completed"); + assert.lengthOf(completions, 2); + for (const completion of completions) { + if (completion.type !== "task.completed") { + continue; + } + assert.equal(completion.payload.status, "completed"); + assert.equal(completion.payload.taskType, "subagent"); + assert.equal(completion.payload.toolUseId, "task-tool-call-1"); + } + assert.deepEqual( + completions.map((event) => + event.type === "task.completed" ? String(event.payload.taskId) : "", + ), + ["task-tool-call-1:0", "task-tool-call-1:1"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("marks the omp task failed when the task tool call fails", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-task-tool-failed"); + const runtimeEvents: Array = []; + const taskCompleted = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TASK_TOOL_FAIL: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) === String(threadId) && event.type === "task.completed") { + yield* Deferred.succeed(taskCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "spawn a failing subagent", + attachments: [], + }); + yield* Deferred.await(taskCompleted); + + const completed = runtimeEvents.find( + (event) => String(event.threadId) === String(threadId) && event.type === "task.completed", + ); + assert.isDefined(completed); + if (completed?.type === "task.completed") { + assert.equal(String(completed.payload.taskId), "task-tool-call-1"); + assert.equal(completed.payload.status, "failed"); + assert.equal(completed.payload.summary, "subagent failed to finish"); + assert.equal(completed.payload.taskType, "subagent"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. The other tests here call startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-consumer-outlives-start-session"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sawContentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "content.delta" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sawContentDelta, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("omp"), model: "openai/gpt-5.4" }, + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello mock", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sawContentDelta).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); +}); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts new file mode 100644 index 000000000000..970732aae50a --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -0,0 +1,1649 @@ +/** + * OmpAdapterLive — Oh My Pi CLI (`omp acp`) via ACP. + * + * @module OmpAdapterLive + */ + +import { + ApprovalRequestId, + type OmpSettings, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + type UserInputQuestion, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + RuntimeTaskId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + type AcpToolCallState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyOmpAcpModelSelection, + makeOmpAcpRuntime, + resolveOmpAcpBaseModelId, +} 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"); +const OMP_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["default"]; + +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 — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface 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; + /** Turns interrupted while sendTurn was still preparing (before the prompt + * reached the wire). acp.cancel is a no-op at that point, so sendTurn + * checks this set at its prompt checkpoints instead. Entries are removed + * when the turn settles. */ + readonly cancelledTurnIds: Set; + /** omp subagent spawns (task tool calls) keyed by toolCallId, awaiting a + * terminal tool_call_update so task.completed can repeat the linkage. */ + readonly ompSubagentTasks: Map>; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + /** Serializes the session-configuration write and the session/prompt + * dispatch registration for one ACP session: omp applies model writes to + * the shared session, so two concurrent sendTurns must not interleave + * set-A, set-B, prompt-A. The permit is held only from the configuration + * write until the prompt is registered as active (or its fiber exits), + * never across the prompt itself, so steers stay concurrent. */ + readonly dispatchLock: Semaphore.Semaphore; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function 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; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + // omp only advertises `default` and `plan` modes; approval behavior is a + // spawn-time concern (CLI approval flags), so every runtime mode resolves + // to the implement mode here. + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect<{ readonly model: string | undefined }, E> { + return Effect.gen(function* () { + let appliedModel: string | undefined; + if (input.modelSelection) { + appliedModel = (yield* applyOmpAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + })).model; + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return { model: appliedModel }; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + return { model: appliedModel }; + }); +} + +/** + * Maps an approval decision to the option id the agent actually advertised. + * Matches on ACP `kind` (the contract) rather than free-form ids — omp's + * PERMISSION_OPTIONS use snake_case. Edge cases carried from the review + * guidance on #8583: options with blank ids are unusable and skipped; agents + * that omit allow_always get "always allow this session" mapped onto their + * allow_once; and when nothing usable exists the caller settles the request + * as cancelled instead of answering with an id the agent never advertised. + */ +export function selectOmpPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const pick = (kind: string) => { + const match = request.options.find((option) => option.kind === kind); + return typeof match?.optionId === "string" && match.optionId.trim().length > 0 + ? match.optionId.trim() + : undefined; + }; + switch (decision) { + case "accept": + return pick("allow_once"); + case "acceptForSession": + return pick("allow_always") ?? pick("allow_once"); + default: + return pick("reject_once") ?? pick("reject_always"); + } +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +/** + * One omp subagent spawn parsed from a `task` tool call. omp exposes its + * sub-agent dispatch as an ordinary ACP tool call whose `rawInput` matches + * the CLI's task schema: `{ name?, agent, task, effort?, isolated? }` for a + * single spawn, or `{ tasks: [...], context? }` to fan out several. + */ +interface OmpSubagentSpawn { + readonly taskId: string; + readonly title: string; + readonly role?: string; + readonly effort?: string; +} + +const OMP_TASK_TITLE_MAX_CHARS = 80; +const OMP_TASK_RESULT_MAX_CHARS = 500; +const OMP_ELICITATION_TEXT_MAX_CHARS = 2_000; +const OMP_TURN_ERROR_MAX_CHARS = 1_000; + +interface OmpElicitationPropertyLike { + readonly type?: string | undefined; + readonly title?: string | null | undefined; + readonly description?: string | null | undefined; + readonly enum?: ReadonlyArray | null | undefined; +} + +/** + * Structural minimum of a form-mode elicitation request. Deliberately loose: + * the same mapping serves both effect-acp's typed `session/elicitation` + * handler and the flat `elicitation/create` fallback that omp's official ACP + * SDK actually sends. + */ +export interface OmpElicitationFormLike { + readonly mode?: string | undefined; + readonly sessionId?: string | undefined; + readonly message?: string | undefined; + readonly requestedSchema?: + | { + readonly type?: string | undefined; + readonly properties?: Readonly> | undefined; + } + | undefined; +} + +/** + * Maps an omp form elicitation's JSON-schema properties onto T3 user-input + * questions. Select-style properties (string+enum, boolean) get option lists; + * everything else falls back to free text (the web composer supports custom + * answers). + */ +export function ompElicitationQuestionsFromForm( + params: OmpElicitationFormLike, +): ReadonlyArray { + const fallbackQuestion = params.message?.trim() || "Oh My Pi requests input."; + return Object.entries(params.requestedSchema?.properties ?? {}).map(([key, property]) => { + const enumValues = + property.type === "string" + ? (property.enum ?? []).filter((value) => value.trim().length > 0) + : []; + return { + id: key, + header: property.title?.trim() || key, + question: truncateTaskText( + property.description?.trim() || fallbackQuestion, + OMP_ELICITATION_TEXT_MAX_CHARS, + ), + multiSelect: false, + options: + enumValues.length > 0 + ? enumValues.map((value) => ({ label: value, description: value })) + : property.type === "boolean" + ? [ + { label: "True", description: "Yes" }, + { label: "False", description: "No" }, + ] + : [], + } satisfies UserInputQuestion; + }); +} + +/** + * Maps T3 user-input answers back onto an elicitation response content + * object. Answer values are the selected option labels (or custom free text); + * boolean properties translate the "True"/"False" labels back, array + * properties keep string arrays. Keys without a usable answer are omitted. + */ +export function ompElicitationContentFromAnswers( + params: OmpElicitationFormLike, + answers: ProviderUserInputAnswers, +): Record> { + const content: Record> = {}; + for (const [key, property] of Object.entries(params.requestedSchema?.properties ?? {})) { + const raw = answers[key]; + if (raw === undefined || raw === null) { + continue; + } + if (property.type === "boolean") { + const value = + raw === true || raw === "True" + ? true + : raw === false || raw === "False" + ? false + : undefined; + if (value !== undefined) { + content[key] = value; + } + continue; + } + if (property.type === "array") { + const values = Array.isArray(raw) + ? raw.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : typeof raw === "string" && raw.trim().length > 0 + ? [raw] + : []; + if (values.length > 0) { + content[key] = values; + } + continue; + } + if (typeof raw === "string" && raw.trim().length > 0) { + content[key] = raw; + } else if (typeof raw === "number" || typeof raw === "boolean") { + content[key] = raw; + } else if (Array.isArray(raw)) { + const first = raw.find( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + if (first !== undefined) { + content[key] = first; + } + } + } + return content; +} + +function truncateTaskText(text: string, maxChars: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars - 1)}…` : collapsed; +} + +function optionalTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseOmpSubagentSpawnItem( + taskId: string, + item: Record, +): OmpSubagentSpawn | undefined { + const task = optionalTrimmedString(item.task); + if (!task) { + return undefined; + } + const role = optionalTrimmedString(item.agent); + const effort = optionalTrimmedString(item.effort); + return { + taskId, + title: truncateTaskText(optionalTrimmedString(item.name) ?? task, OMP_TASK_TITLE_MAX_CHARS), + ...(role ? { role } : {}), + ...(effort ? { effort } : {}), + }; +} + +/** + * Every key omp's task tool schema accepts. ACP does not carry the tool + * name, so this allowlist is the identity check: any rawInput with a foreign + * key (e.g. { task: "…", url: "…" }) belongs to another tool and must not be + * projected as a subagent spawn. + */ +const OMP_TASK_TOOL_INPUT_KEYS: Record = { + name: true, + agent: true, + task: true, + tasks: true, + context: true, + effort: true, + isolated: true, + outputSchema: true, + schemaMode: true, + label: true, + apply: true, + merge: true, + handle: true, +}; + +export function parseOmpSubagentSpawns( + toolCallId: string, + rawInput: unknown, +): ReadonlyArray { + if (!isRecord(rawInput)) { + return []; + } + if (!Object.keys(rawInput).every((key) => OMP_TASK_TOOL_INPUT_KEYS[key] === true)) { + return []; + } + if (Array.isArray(rawInput.tasks)) { + return rawInput.tasks.flatMap((item, index) => { + if (!isRecord(item)) { + return []; + } + const spawn = parseOmpSubagentSpawnItem(`${toolCallId}:${index}`, item); + return spawn ? [spawn] : []; + }); + } + const single = parseOmpSubagentSpawnItem(toolCallId, rawInput); + return single ? [single] : []; +} + +function summarizeOmpTaskResult(rawOutput: unknown): string | undefined { + const text = + typeof rawOutput === "string" + ? rawOutput + : isRecord(rawOutput) + ? ["output", "content", "result", "text", "stdout"] + .map((field) => rawOutput[field]) + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + : undefined; + return text ? truncateTaskText(text, OMP_TASK_RESULT_MAX_CHARS) : undefined; +} + +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 Oh My Pi runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + 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) => + 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, + ) => + 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: "acp.jsonrpc", + method: "session/update", + rawPayload, + }), + ); + }); + + /** + * Projects omp's `task` tool calls into Agents-panel lifecycle events. + * The plain tool_call runtime event is still emitted alongside (Claude + * shows its Task tool in the timeline as well); omp never forwards + * subagent-internal activity, so only start and terminal rows exist. + */ + const emitOmpSubagentEvents = (ctx: OmpSessionContext, toolCall: AcpToolCallState) => + Effect.gen(function* () { + let tracked = ctx.ompSubagentTasks.get(toolCall.toolCallId); + if (tracked === undefined) { + const spawns = parseOmpSubagentSpawns(toolCall.toolCallId, toolCall.data.rawInput); + if (spawns.length === 0) { + return; + } + ctx.ompSubagentTasks.set(toolCall.toolCallId, spawns); + tracked = spawns; + for (const spawn of spawns) { + yield* offerRuntimeEvent({ + type: "task.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(spawn.taskId), + taskType: "subagent", + title: spawn.title, + ...(spawn.role ? { role: spawn.role } : {}), + ...(spawn.effort ? { effort: spawn.effort } : {}), + toolUseId: toolCall.toolCallId, + }, + }); + } + } + + if (toolCall.status !== "completed" && toolCall.status !== "failed") { + return; + } + ctx.ompSubagentTasks.delete(toolCall.toolCallId); + const summary = summarizeOmpTaskResult(toolCall.data.rawOutput); + for (const spawn of tracked) { + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(spawn.taskId), + status: toolCall.status, + ...(summary ? { summary } : {}), + taskType: "subagent", + title: spawn.title, + ...(spawn.role ? { role: spawn.role } : {}), + ...(spawn.effort ? { effort: spawn.effort } : {}), + toolUseId: toolCall.toolCallId, + }, + }); + } + }); + + 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); + }; + + // Thread locks are deliberately never removed from threadLocksRef: + // deleting a lock while its permit is held or queued would let a later + // startSession build a fresh semaphore and run concurrently with the + // operations queued on the old one — worse than the bounded leak of one + // semaphore per thread id. + 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 OmpSettings 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, + // Approval behavior is spawn-time for omp (CLI approval flags), + // so the runtime mode travels into the spawn input here. + 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, + }), + ), + ); + // omp's extension wrapper asks for approval through ACP + // elicitation. effect-acp types that as `session/elicitation`, but + // omp 18.0.6's official @agent-client-protocol/sdk sends + // `elicitation/create` — so both registrations share one flow. The + // ext fallback controls its own wire format and answers with the + // FLAT shape omp expects ({ action: "accept", content }), whereas + // the typed handler keeps effect-acp's nested response schema. + const runElicitationFlow = ( + method: string, + rawParams: unknown, + formParams: OmpElicitationFormLike, + ) => + Effect.gen(function* () { + yield* logNative(input.threadId, method, rawParams); + const questions = ompElicitationQuestionsFromForm(formParams); + if (questions.length === 0) { + return { 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] }, + raw: { + source: "acp.jsonrpc", + method, + payload: rawParams, + }, + }); + 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 }, + }); + const content = ompElicitationContentFromAnswers(formParams, resolved); + return Object.keys(content).length === 0 + ? { action: "cancel" as const } + : { action: "accept" as const, content }; + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Oh My Pi ACP elicitation request.", + cause, + }), + ), + ); + + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const optionId = + resolved === "cancel" ? undefined : selectOmpPermissionOptionId(params, resolved); + return { + outcome: + optionId === undefined + ? ({ outcome: "cancelled" } as const) + : ({ outcome: "selected" as const, optionId } as const), + }; + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Oh My Pi ACP permission request.", + cause, + }), + ), + ), + ); + yield* acp.handleElicitation((params) => + params.mode !== "form" + ? Effect.succeed({ action: { action: "cancel" as const } }) + : runElicitationFlow("session/elicitation", params, params).pipe( + Effect.map((flat) => ({ action: flat })), + ), + ); + yield* acp.handleUnknownExtRequest((method, params) => { + if (method !== "elicitation/create") { + return Effect.fail(EffectAcpErrors.AcpRequestError.methodNotFound(method)); + } + if (!isRecord(params) || (params.mode !== undefined && params.mode !== "form")) { + return Effect.succeed({ action: "cancel" as const }); + } + return runElicitationFlow("elicitation/create", params, params); + }); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: 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, + }; + + const dispatchLock = yield* Semaphore.make(1); + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + cancelledTurnIds: new Set(), + ompSubagentTasks: new Map(), + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + dispatchLock, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + yield* emitOmpSubagentEvents(ctx, event.toolCall); + return; + case "ThoughtDelta": + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event._tag === "ContentDelta" && event.itemId + ? { itemId: event.itemId } + : {}), + ...(event._tag === "ThoughtDelta" + ? { streamKind: "reasoning_text" as const } + : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Oh My Pi 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: "Oh My Pi 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) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. Bind the active turn id in the + // same synchronous stretch: after the increment, a concurrent + // sendTurn must already see this turn id or it would steer onto the + // previous one. + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + + // interruptTurn cannot reach a turn whose prompt has not been sent + // yet (acp.cancel is a no-op pre-prompt), so cancelled turn ids are + // checked at both checkpoints instead. + const settleIfCancelled = () => + Effect.gen(function* () { + if (!ctx.cancelledTurnIds.has(turnId)) { + return false; + } + if (ctx.promptsInFlight === 1) { + ctx.cancelledTurnIds.delete(turnId); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "cancelled", stopReason: "cancelled" }, + }); + } + // With other prompts in flight the mark stays put: deleting it + // here would let the remaining prompts reach acp.prompt, and the + // last one to finish (see `ensuring` below) settles the turn. + return true; + }); + + let turnStartedEmitted = false; + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveOmpAcpBaseModelId(model); + // Session configuration (model + options + mode) is applied as late + // as possible — immediately before dispatch — so a concurrent + // sendTurn cannot interleave a different model write between this + // turn's configuration and its prompt. + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (yield* settleIfCancelled()) { + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + 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) { + // omp ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } + 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.", + }); + } + if (yield* settleIfCancelled()) { + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + const dispatched = yield* Deferred.make(); + const promptEffect = ctx.acp + .prompt({ prompt: promptParts }, { dispatched }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + // omp applies model writes to the shared ACP session, so the + // configuration write, the turn.started stamp, and the prompt's + // dispatch registration must be atomic per session: without the + // lock two concurrent sendTurns could interleave set-A, set-B, + // prompt-A and run prompt A under model B. The permit is released + // as soon as the prompt registers as active (or its fiber exits + // without registering), never held across the prompt itself, so + // steers stay concurrent. + const { configuration, promptFiber } = yield* ctx.dispatchLock.withPermit( + Effect.gen(function* () { + const configuration = yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + // Cancel checkpoint inside the permit: a turn interrupted + // while its configuration write was in flight must never + // stamp turn.started or register a session/prompt. + if (yield* settleIfCancelled()) { + return { + configuration, + promptFiber: undefined as + | Fiber.Fiber + | undefined, + }; + } + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: + (configuration.model ?? resolvedModel) + ? { model: configuration.model ?? resolvedModel } + : {}, + }); + turnStartedEmitted = true; + } + const promptFiber = yield* promptEffect.pipe(Effect.forkIn(ctx.scope)); + yield* Deferred.await(dispatched).pipe( + Effect.race(Fiber.await(promptFiber).pipe(Effect.asVoid)), + ); + return { + configuration, + promptFiber: promptFiber as + | Fiber.Fiber + | undefined, + }; + }), + ); + const effectiveModel = configuration.model ?? resolvedModel; + + if (promptFiber === undefined) { + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if (yield* settleIfCancelled()) { + // The cancel landed after dispatch registration: interrupt the + // prompt fiber so no session/prompt outlives the cancelled turn. + yield* Fiber.interrupt(promptFiber); + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + const result = yield* Fiber.join(promptFiber).pipe( + // join does not propagate interruption to the joined fiber: an + // interruptTurn or stopSession landing here would otherwise + // orphan a live session/prompt. + Effect.onInterrupt(() => Fiber.interrupt(promptFiber)), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(effectiveModel ? { model: effectiveModel } : {}), + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1 && !ctx.stopped) { + ctx.cancelledTurnIds.delete(turnId); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + // A failure after turn.started must still close the turn: surface + // it as turn.completed(failed) before the error propagates so the + // UI never waits on a dead turn. Same settle rule as the success + // path — only the last remaining prompt may settle. + Effect.tapError((error) => + ctx.promptsInFlight !== 1 || + ctx.stopped || // session torn down or replaced mid-flight; a late failure must not publish on a dead/new session + (!turnStartedEmitted && steeringTurnId === undefined) + ? Effect.void + : Effect.gen(function* () { + ctx.cancelledTurnIds.delete(turnId); + const message = + typeof error === "object" && error !== null && "message" in error + ? error.message + : undefined; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "failed", + stopReason: null, + errorMessage: truncateTaskText( + typeof message === "string" ? message : String(error), + OMP_TURN_ERROR_MAX_CHARS, + ), + }, + }); + }), + ), + Effect.ensuring( + Effect.gen(function* () { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + // The last prompt of a turn cancelled during preparation + // settles it here: the checkpoints kept the mark because other + // prompts were still in flight. + if (ctx.promptsInFlight === 0 && !ctx.stopped && ctx.cancelledTurnIds.has(turnId)) { + // Finalizers cannot fail; a crypto failure here must not + // mask the sendTurn outcome. + yield* Effect.ignore( + Effect.gen(function* () { + ctx.cancelledTurnIds.delete(turnId); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "cancelled", stopReason: "cancelled" }, + }); + }), + ); + } + }), + ), + ); + }); + + const interruptTurn: OmpAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + // A late interrupt for a turn that already settled must not cancel + // the thread's CURRENT turn: only act when the id matches (or none + // was given, the legacy "cancel whatever is active" form). + if (turnId !== undefined && turnId !== ctx.activeTurnId) { + return; + } + // Pre-prompt cancellation cannot ride acp.cancel (a no-op until the + // prompt is on the wire); sendTurn checks this set at its prompt + // checkpoints and settles the turn as cancelled instead. + if (ctx.activeTurnId !== undefined) { + ctx.cancelledTurnIds.add(ctx.activeTurnId); + } + 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/user_input", + 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* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: 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 Oh My Pi session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + // omp's ACP session cannot rewind its native conversation history: + // rollbackThread only truncates the local turn log, so advertise the + // capability as unsupported instead of reporting a rollback the live + // session does not reflect. + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + 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..8ad2b89914f9 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -0,0 +1,458 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import type * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect, it } from "vite-plus/test"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import type { OmpSettings } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildOmpProviderSnapshot, + buildOmpCapabilitiesFromConfigOptions, + checkOmpProviderStatus, + discoverOmpModelsViaAcp, + getOmpFallbackModels, + resolveOmpAcpConfigUpdates, +} from "./OmpProvider.ts"; + +const node = ( + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + >, +): Effect.Effect => effect.pipe(Effect.provide(NodeServices.layer)); + +const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); +}); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( + extraEnv?: Record, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-mock-", + }); + const wrapperPath = path.join(dir, "fake-omp.sh"); + const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); + const envExports = Object.entries({ T3_ACP_OMP_SHAPES: "1", ...extraEnv }) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${mockAgentCommand} "$@" +`; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +const makeMockAgentWithVersionWrapper = Effect.fn("makeMockAgentWithVersionWrapper")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-version-mock-", + }); + const wrapperPath = path.join(dir, "fake-omp.sh"); + const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); + const script = `#!/bin/sh +export T3_ACP_OMP_SHAPES=1 +if [ "$1" = "--version" ]; then + printf 'omp/18.0.6\\n' + exit 0 +fi +exec ${mockAgentCommand} "$@" +`; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* ( + filePath: string, + attempts = 40, +) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const content = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.catch(() => Effect.void)); + if (content !== undefined) { + if (content.trim().length > 0) { + return content; + } + } + yield* Effect.sleep("50 millis"); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); + +const makeProviderStatusEnvFixture = Effect.fn("makeProviderStatusEnvFixture")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-status-env-", + }); + return { + requestLogPath: path.join(tempDir, "requests.ndjson"), + wrapperPath: yield* makeMockAgentWithVersionWrapper(), + }; +}); + +const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix, + }); + const exitLogPath = path.join(tempDir, "exit.log"); + return { + exitLogPath, + wrapperPath: yield* makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + }; +}); + +const ompConfigOptions = [ + { + type: "select", + currentValue: "default", + options: [ + { name: "Default", value: "default" }, + { name: "Plan", value: "plan" }, + ], + category: "mode", + id: "mode", + name: "Mode", + }, + { + type: "select", + currentValue: "zhipu-coding-plan/glm-5.3", + options: [ + { name: "GLM 5.3", value: "zhipu-coding-plan/glm-5.3" }, + { name: "Claude Opus 4.6", value: "anthropic/claude-opus-4-6" }, + { name: "GPT-5.4", value: "openai/gpt-5.4" }, + ], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "high", + options: [ + { name: "Off", value: "off" }, + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Max", value: "max" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, +] satisfies ReadonlyArray; + +const baseOmpSettings: OmpSettings = { + enabled: true, + binaryPath: "omp", + customModels: [], +}; +const missingOmpBinaryPath = "/definitely/not/installed/t3-omp"; +const ompCliCommandMissingMessage = [ + `Oh My Pi CLI command \`${missingOmpBinaryPath}\` was not found.`, + `Install or enable the omp CLI, make sure \`${missingOmpBinaryPath}\` is on PATH, then restart T3 Code.`, + "See https://github.com/can1357/oh-my-pi.", +].join(" "); + +describe("getOmpFallbackModels", () => { + it("does not publish any built-in omp models before ACP discovery", () => { + expect( + getOmpFallbackModels({ + customModels: ["internal/omp-model"], + }).map((model) => model.slug), + ).toEqual(["internal/omp-model"]); + }); +}); + +describe("buildOmpProviderSnapshot", () => { + it("downgrades ready status to warning when ACP model discovery times out", () => { + expect( + buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: baseOmpSettings, + version: "18.0.6", + discoveryWarning: "Oh My Pi ACP model discovery timed out after 15000ms.", + }), + ).toMatchObject({ + status: "warning", + message: "Oh My Pi ACP model discovery timed out after 15000ms.", + models: [], + }); + }); + + it("preserves provider error state while appending discovery warnings", () => { + expect( + buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: { + ...baseOmpSettings, + customModels: ["internal/omp-model"], + }, + version: "18.0.6", + status: "error", + message: "Oh My Pi CLI is installed but failed to run.", + discoveryWarning: "Oh My Pi ACP model discovery failed.", + }), + ).toMatchObject({ + status: "error", + message: "Oh My Pi CLI is installed but failed to run. Oh My Pi ACP model discovery failed.", + models: [ + { + slug: "internal/omp-model", + isCustom: true, + }, + ], + }); + }); +}); + +describe("buildOmpCapabilitiesFromConfigOptions", () => { + it("maps the omp thought_level select onto a reasoning effort descriptor", () => { + expect(buildOmpCapabilitiesFromConfigOptions(ompConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Thinking", [ + { id: "off", label: "Off" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + ]), + ], + }), + ); + }); + + it("exposes auto thinking levels on auto models", () => { + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "select", + currentValue: "auto", + options: [ + { name: "Off", value: "off" }, + { name: "Auto", value: "auto" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Thinking", [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto", isDefault: true }, + ]), + ], + }), + ); + }); + + it("returns empty capabilities when no config options are advertised", () => { + expect(buildOmpCapabilitiesFromConfigOptions([])).toEqual( + createModelCapabilities({ optionDescriptors: [] }), + ); + expect(buildOmpCapabilitiesFromConfigOptions(undefined)).toEqual( + createModelCapabilities({ optionDescriptors: [] }), + ); + }); +}); + +describe("checkOmpProviderStatus", () => { + effectIt.live("reports the install docs when the omp CLI command is missing", () => + Effect.gen(function* () { + const provider = yield* node( + checkOmpProviderStatus({ + enabled: true, + binaryPath: missingOmpBinaryPath, + customModels: [], + }), + ); + + expect(provider).toMatchObject({ + installed: false, + status: "error", + auth: { status: "unknown" }, + message: ompCliCommandMissingMessage, + }); + }), + ); + + effectIt.live( + "discovers the meta-provider catalog through ACP with the injected environment", + () => + Effect.gen(function* () { + const { requestLogPath, wrapperPath } = yield* node(makeProviderStatusEnvFixture()); + + const provider = yield* node( + checkOmpProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }, + { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + ), + ); + + expect(provider).toMatchObject({ + installed: true, + version: "18.0.6", + status: "ready", + message: "3 upstream providers configured through Oh My Pi.", + }); + expect(provider.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-opus-4-6", + "zhipu-coding-plan/glm-5.3", + "openai/gpt-5.4", + ]); + expect(provider.models.map((model) => model.subProvider)).toEqual([ + "Anthropic", + "Zhipu Coding Plan", + "Openai", + ]); + const requestLog = yield* node(waitForFileContent(requestLogPath)); + expect(requestLog).toContain("initialize"); + }), + ); +}); + +describe("discoverOmpModelsViaAcp", () => { + effectIt.live("builds the model catalog from the ACP model config option", () => + Effect.gen(function* () { + const wrapperPath = yield* node(makeMockAgentWrapper()); + + const models = yield* node( + discoverOmpModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }).pipe(Effect.scoped), + ); + + expect(models.map((model) => model.slug)).toEqual([ + "anthropic/claude-opus-4-6", + "zhipu-coding-plan/glm-5.3", + "openai/gpt-5.4", + ]); + expect(models[0]).toMatchObject({ + name: "Claude Opus 4.6", + subProvider: "Anthropic", + isCustom: false, + }); + }), + ); + + effectIt.live("closes the ACP probe runtime after discovery completes", () => + Effect.gen(function* () { + const { exitLogPath, wrapperPath } = yield* node( + makeExitLogFixture("omp-provider-exit-log-"), + ); + + yield* node( + discoverOmpModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }), + ); + + const exitLog = yield* node(waitForFileContent(exitLogPath)); + expect(exitLog).toContain("SIGTERM"); + }), + ); +}); + +describe("resolveOmpAcpConfigUpdates", () => { + it("maps reasoning selections onto the omp thinking config option", () => { + expect( + resolveOmpAcpConfigUpdates(ompConfigOptions, [{ id: "reasoning", value: "max" }]), + ).toEqual([{ configId: "thinking", value: "max" }]); + }); + + it("maps reasoning off so the adapter can clear a prior thinking selection", () => { + expect( + resolveOmpAcpConfigUpdates(ompConfigOptions, [{ id: "reasoning", value: "off" }]), + ).toEqual([{ configId: "thinking", value: "off" }]); + }); + + it("maps reasoning auto onto the omp thinking config option", () => { + expect( + resolveOmpAcpConfigUpdates(ompConfigOptions, [{ id: "reasoning", value: "auto" }]), + ).toEqual([]); + expect( + resolveOmpAcpConfigUpdates( + [ + { + type: "select", + currentValue: "off", + options: [ + { name: "Off", value: "off" }, + { name: "Auto", value: "auto" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, + ], + [{ id: "reasoning", value: "auto" }], + ), + ).toEqual([{ configId: "thinking", value: "auto" }]); + }); + + it("ignores unknown reasoning values and empty selections", () => { + expect( + resolveOmpAcpConfigUpdates(ompConfigOptions, [{ id: "reasoning", value: "ludicrous" }]), + ).toEqual([]); + expect(resolveOmpAcpConfigUpdates(ompConfigOptions, undefined)).toEqual([]); + expect(resolveOmpAcpConfigUpdates([], [{ id: "reasoning", value: "max" }])).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts new file mode 100644 index 000000000000..2d8f03074b56 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -0,0 +1,790 @@ +import type { + OmpSettings, + ModelCapabilities, + ProviderOptionSelection, + ServerProvider, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +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 { + createModelCapabilities, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; + +const OMP_PRESENTATION = { + displayName: "Oh My Pi", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const OMP_CLI_DOCS_URL = "https://github.com/can1357/oh-my-pi"; +const OMP_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ + "Oh My Pi ACP model discovery failed.", + "The omp CLI setup may be incomplete; install or enable the omp CLI, restart T3 Code, and try again.", + `See ${OMP_CLI_DOCS_URL}.`, + "Check server logs for ACP details.", +].join(" "); + +export function buildInitialOmpProviderSnapshot( + ompSettings: OmpSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getOmpFallbackModels(ompSettings); + + if (!ompSettings.enabled) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Oh My Pi is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Oh My Pi availability...", + }, + }); + }); +} + +interface OmpSessionSelectOption { + readonly value: string; + readonly name: string; +} + +export function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + } satisfies OmpSessionSelectOption, + ] + : entry.options.map( + (option) => + ({ + value: option.value.trim(), + name: option.name.trim(), + }) satisfies OmpSessionSelectOption, + ), + ); +} + +function normalizeOmpReasoningValue(value: string | null | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + switch (normalized) { + case "off": + case "none": + return "off"; + // omp's thinking select legitimately offers {off, auto} on auto models. + case "auto": + return "auto"; + case "low": + case "medium": + case "high": + case "max": + return normalized; + case "xhigh": + case "extra-high": + case "extra high": + return "xhigh"; + default: + return undefined; + } +} + +function getOmpConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { + return option.category?.trim().toLowerCase() ?? ""; +} + +function isOmpEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (getOmpConfigOptionCategory(option) === "thought_level") { + return true; + } + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return ( + id === "effort" || + id === "reasoning" || + id === "thinking" || + name === "effort" || + name === "reasoning" || + name.includes("effort") || + name.includes("reasoning") + ); +} + +function findOmpEffortConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + const candidates = configOptions.filter( + (option) => option.type === "select" && isOmpEffortConfigOption(option), + ); + return ( + candidates.find((option) => getOmpConfigOptionCategory(option) === "thought_level") ?? + candidates.find((option) => option.id.trim().toLowerCase() === "effort") ?? + candidates.find((option) => getOmpConfigOptionCategory(option) === "model_option") ?? + candidates[0] + ); +} + +function isOmpContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "context" || id === "context_size" || name.includes("context"); +} + +function isOmpFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "fast" || name === "fast" || name.includes("fast mode"); +} + +function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.type === "boolean") { + return true; + } + if (option.type !== "select") { + return false; + } + const values = new Set( + flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()), + ); + return values.has("true") && values.has("false"); +} + +function getBooleanCurrentValue( + option: EffectAcpSchema.SessionConfigOption | undefined, +): boolean | undefined { + if (!option) { + return undefined; + } + if (option.type === "boolean") { + return option.currentValue; + } + if (option.type !== "select") { + return undefined; + } + const normalized = option.currentValue?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return undefined; +} + +export function buildOmpCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + if (!configOptions || configOptions.length === 0) { + return EMPTY_CAPABILITIES; + } + + const reasoningConfig = findOmpEffortConfigOption(configOptions); + const reasoningEffortLevels = + reasoningConfig?.type === "select" + ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { + const normalizedValue = normalizeOmpReasoningValue(entry.value); + if (!normalizedValue) { + return []; + } + return [ + { + value: normalizedValue, + label: entry.name, + ...(normalizeOmpReasoningValue(reasoningConfig.currentValue) === normalizedValue + ? { isDefault: true } + : {}), + }, + ]; + }) + : []; + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isOmpContextConfigOption(option), + ); + const contextWindowOptions = + contextOption?.type === "select" + ? flattenSessionConfigSelectOptions(contextOption).map((entry) => { + if (contextOption.currentValue?.trim() === entry.value) { + return { + value: entry.value, + label: entry.name, + isDefault: true, + }; + } + return { + value: entry.value, + label: entry.name, + }; + }) + : []; + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isOmpFastConfigOption(option), + ); + const fastCurrentValue = getBooleanCurrentValue(fastOption); + const optionDescriptors = [ + ...(reasoningEffortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []), + ...(contextWindowOptions.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: contextOption?.name?.trim() || "Context Window", + options: contextWindowOptions, + }), + ] + : []), + ...(fastOption && isBooleanLikeConfigOption(fastOption) + ? [ + typeof fastCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + currentValue: fastCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + }), + ] + : []), + ]; + + return createModelCapabilities({ + optionDescriptors, + }); +} + +/** + * Existence probe without the select guard: ACP permits a boolean option + * named `model`, and callers that write model values must distinguish + * "no model option at all" from "a model option that cannot accept a slug". + */ +export function findOmpModelConfigOptionAny( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return ( + configOptions.find((option) => getOmpConfigOptionCategory(option) === "model") ?? + configOptions.find((option) => option.id.trim().toLowerCase() === "model") + ); +} + +export function findOmpModelConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return ( + configOptions.find( + (option) => option.type === "select" && getOmpConfigOptionCategory(option) === "model", + ) ?? + configOptions.find( + (option) => option.type === "select" && option.id.trim().toLowerCase() === "model", + ) + ); +} + +function titleCaseSlug(value: string): string { + const segments: Array = []; + for (const segment of value.split(/[-_/]+/)) { + if (segment.length > 0) { + segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + } + return segments.join(" "); +} + +/** + * Oh My Pi is a meta provider (like OpenCode): model ids advertised through + * the ACP `model` config option are `provider/model` pairs routed to upstream + * providers the user configured inside omp. Mirror OpenCode's presentation + * by surfacing the upstream provider as `subProvider` and sorting the catalog + * by display name so the picker stays usable with 100+ entries. + */ +function buildOmpDiscoveredModelsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + const modelOption = findOmpModelConfigOption(configOptions ?? []); + if (!modelOption) { + return []; + } + // The probe session's configOptions describe the model it currently has + // selected (omp re-validates dependent options per model). Advertising + // those capabilities for every catalog entry would offer invalid + // reasoning choices on other models, so only the probed model carries + // them; the rest report null and the adapter re-reads options per model + // at selection time. + const currentModelId = + modelOption.type === "select" ? modelOption.currentValue?.trim() : undefined; + const probedCapabilities = buildOmpCapabilitiesFromConfigOptions(configOptions); + const seen = new Set(); + const models = flattenSessionConfigSelectOptions(modelOption).flatMap((entry) => { + if (!entry.value || seen.has(entry.value)) { + return []; + } + seen.add(entry.value); + const slashIndex = entry.value.indexOf("/"); + const subProvider = + slashIndex > 0 ? titleCaseSlug(entry.value.slice(0, slashIndex)) : undefined; + return [ + { + slug: entry.value, + name: entry.name || entry.value, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: entry.value === currentModelId ? probedCapabilities : null, + } satisfies ServerProviderModel, + ]; + }); + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function countOmpUpstreamProviders(models: ReadonlyArray): number { + const prefixes = new Set(); + for (const model of models) { + const slashIndex = model.slug.indexOf("/"); + if (slashIndex > 0) { + prefixes.add(model.slug.slice(0, slashIndex)); + } + } + return prefixes.size; +} + +const makeOmpAcpProbeRuntime = (ompSettings: OmpSettings, environment?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: ompSettings.binaryPath || "omp", + args: ["acp"], + cwd: process.cwd(), + ...(environment ? { env: environment } : {}), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + authMethodId: "agent", + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export const discoverOmpModelsViaAcp = ( + ompSettings: OmpSettings, + environment?: NodeJS.ProcessEnv, +) => + makeOmpAcpProbeRuntime(ompSettings, environment).pipe( + Effect.flatMap((acp) => + Effect.map(acp.start(), (started) => + buildOmpDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions), + ), + ), + Effect.scoped, + ); + +export function getOmpFallbackModels( + ompSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], ompSettings.customModels, EMPTY_CAPABILITIES); +} + +function normalizeOmpConfigOptionToken(value: string | null | undefined): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-") ?? "" + ); +} + +function findOmpSelectOptionValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + matcher: (option: OmpSessionSelectOption) => boolean, +): string | undefined { + return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; +} + +function findOmpBooleanConfigValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + requested: boolean, +): string | boolean | undefined { + if (!configOption) { + return undefined; + } + if (configOption.type === "boolean") { + return requested; + } + return findOmpSelectOptionValue( + configOption, + (option) => normalizeOmpConfigOptionToken(option.value) === String(requested), + ); +} + +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 reasoningOption = findOmpEffortConfigOption(configOptions); + const requestedReasoning = normalizeOmpReasoningValue( + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); + if (reasoningOption && requestedReasoning) { + const value = findOmpSelectOptionValue(reasoningOption, (option) => { + const normalizedValue = normalizeOmpReasoningValue(option.value); + const normalizedName = normalizeOmpReasoningValue(option.name); + return normalizedValue === requestedReasoning || normalizedName === requestedReasoning; + }); + if (value) { + updates.push({ configId: reasoningOption.id, value }); + } + } + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isOmpContextConfigOption(option), + ); + const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); + if (contextOption && requestedContextWindow) { + const value = findOmpSelectOptionValue( + contextOption, + (option) => + normalizeOmpConfigOptionToken(option.value) === + normalizeOmpConfigOptionToken(requestedContextWindow) || + normalizeOmpConfigOptionToken(option.name) === + normalizeOmpConfigOptionToken(requestedContextWindow), + ); + if (value) { + updates.push({ configId: contextOption.id, value }); + } + } + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isOmpFastConfigOption(option), + ); + const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findOmpBooleanConfigValue(fastOption, requestedFastMode); + if (value !== undefined) { + updates.push({ configId: fastOption.id, value }); + } + } + + return updates; +} + +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts: Array = []; + for (const message of messages) { + const trimmed = message?.trim(); + if (trimmed) { + parts.push(trimmed); + } + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function buildOmpCliCommandMissingMessage(binaryPath: string): string { + return [ + `Oh My Pi CLI command \`${binaryPath}\` was not found.`, + `Install or enable the omp CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${OMP_CLI_DOCS_URL}.`, + ].join(" "); +} + +export function buildOmpProviderSnapshot(input: { + readonly checkedAt: string; + readonly ompSettings: OmpSettings; + readonly version: string | null; + readonly status?: Exclude; + readonly message?: string; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const status = input.status ?? "ready"; + const message = joinProviderMessages(input.message, input.discoveryWarning); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: input.ompSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + input.ompSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.version, + status: input.discoveryWarning && status === "ready" ? "warning" : status, + auth: { status: "unknown" }, + ...(message ? { message } : {}), + }, + }); +} + +const runOmpVersionCommand = (ompSettings: OmpSettings, environment?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = ompSettings.binaryPath || "omp"; + const spawnCommand = yield* resolveSpawnCommand( + command, + ["--version"], + environment ? { env: environment } : {}, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, + }), + ); + }); + +export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(function* ( + ompSettings: OmpSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getOmpFallbackModels(ompSettings); + + if (!ompSettings.enabled) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Oh My Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runOmpVersionCommand(ompSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + yield* Effect.logWarning("Oh My Pi CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: ompSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? buildOmpCliCommandMissingMessage(ompSettings.binaryPath || "omp") + : "Failed to execute Oh My Pi CLI health check.", + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: ompSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi CLI is installed but timed out while running `omp --version`.", + }, + }); + } + + const versionOutput = versionProbe.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Oh My Pi CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: OMP_PRESENTATION, + enabled: ompSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Oh My Pi CLI is installed but failed to run.", + }, + }); + } + + let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; + const discoveryExit = yield* Effect.exit( + discoverOmpModelsViaAcp(ompSettings, environment).pipe( + Effect.timeoutOption(OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Oh My Pi ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + discoveryWarning = OMP_ACP_MODEL_DISCOVERY_FAILED_MESSAGE; + } else if (Option.isNone(discoveryExit.value)) { + discoveryWarning = `Oh My Pi ACP model discovery timed out after ${OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } else if (discoveryExit.value.value.length === 0) { + discoveryWarning = "Oh My Pi ACP model discovery returned no built-in models."; + } else { + discoveredModels = discoveryExit.value; + } + const resolvedModels = Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ); + // Meta-provider reporting (mirrors OpenCode): tell the user how many + // upstream providers the discovered `provider/model` catalog routes to. + const upstreamCount = countOmpUpstreamProviders(resolvedModels); + return buildOmpProviderSnapshot({ + checkedAt, + ompSettings, + version, + discoveredModels: resolvedModels, + ...(upstreamCount > 0 + ? { + message: `${upstreamCount} upstream provider${upstreamCount === 1 ? "" : "s"} configured through Oh My Pi.`, + } + : {}), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); + +/** + * Background maintenance enrichment for an Oh My Pi snapshot. + * + * Used by `OmpDriver` as the `makeManagedServerProvider.enrichSnapshot` + * hook: republishes update/version advisory metadata without performing any + * model or capability discovery. Oh My Pi model data comes exclusively from + * the probe ACP session during provider status checks. + */ +export const enrichOmpSnapshot = (input: { + readonly settings: OmpSettings; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value) => value); + + if (!settings.enabled || snapshot.auth.status === "unauthenticated") { + return Effect.void; + } + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Oh My Pi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }).pipe(Effect.asVoid), + ), + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..1d8a47e919bc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2616,6 +2616,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "omp", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts new file mode 100644 index 000000000000..4c339dceca61 --- /dev/null +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -0,0 +1,19 @@ +/** + * OmpAdapter — shape type for the Oh My Pi (`omp`) provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/OmpDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module OmpAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OmpAdapterShape — per-instance Oh My Pi adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface OmpAdapterShape extends ProviderAdapterShape {} 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..86ee94ab78e9 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -0,0 +1,311 @@ +import * as Effect from "effect/Effect"; +import { it as effectIt } from "@effect/vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + applyOmpAcpModelSelection, + buildOmpAcpSpawnInput, + ompAcpSpawnArgs, + resolveOmpAcpBaseModelId, +} from "./OmpAcpSupport.ts"; + +const ompConfigOptions: ReadonlyArray = [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "plan", name: "Plan" }, + ], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "zhipu-coding-plan/glm-5.3", + options: [ + { value: "zhipu-coding-plan/glm-5.3", name: "GLM 5.3" }, + { value: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { value: "openai/gpt-5.4", name: "GPT-5.4" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "high", + options: [ + { value: "off", name: "Off" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, +]; + +describe("ompAcpSpawnArgs", () => { + it("maps runtime modes onto omp approval flags", () => { + expect(ompAcpSpawnArgs(undefined)).toEqual(["acp", "--approval-mode=always-ask"]); + expect(ompAcpSpawnArgs("approval-required")).toEqual(["acp", "--approval-mode=always-ask"]); + expect(ompAcpSpawnArgs("auto-accept-edits")).toEqual(["acp", "--approval-mode=write"]); + expect(ompAcpSpawnArgs("auto")).toEqual(["acp", "--auto-approve"]); + expect(ompAcpSpawnArgs("full-access")).toEqual(["acp", "--approval-mode=yolo"]); + }); +}); + +describe("buildOmpAcpSpawnInput", () => { + it("builds the default omp ACP command", () => { + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "omp", + args: ["acp", "--approval-mode=always-ask"], + cwd: "/tmp/project", + }); + }); + + it("uses the configured binary path and forwards the runtime mode", () => { + expect( + buildOmpAcpSpawnInput( + { binaryPath: "/usr/local/bin/omp" }, + "/tmp/project", + undefined, + "full-access", + ), + ).toEqual({ + command: "/usr/local/bin/omp", + args: ["acp", "--approval-mode=yolo"], + cwd: "/tmp/project", + }); + }); + + it("passes the injected environment through without extra variables", () => { + const environment = { PATH: "/usr/bin" } as NodeJS.ProcessEnv; + expect(buildOmpAcpSpawnInput(undefined, "/tmp/project", environment)).toEqual({ + command: "omp", + args: ["acp", "--approval-mode=always-ask"], + cwd: "/tmp/project", + env: environment, + }); + }); +}); + +describe("resolveOmpAcpBaseModelId", () => { + it("passes provider/model ids through and drops bracket traits", () => { + expect(resolveOmpAcpBaseModelId("zhipu-coding-plan/glm-5.3")).toBe("zhipu-coding-plan/glm-5.3"); + expect(resolveOmpAcpBaseModelId("openai/gpt-5.4[reasoning=high]")).toBe("openai/gpt-5.4"); + expect(resolveOmpAcpBaseModelId(" anthropic/claude-opus-4-6 ")).toBe( + "anthropic/claude-opus-4-6", + ); + expect(resolveOmpAcpBaseModelId(undefined)).toBeUndefined(); + expect(resolveOmpAcpBaseModelId("")).toBeUndefined(); + expect(resolveOmpAcpBaseModelId(" ")).toBeUndefined(); + }); +}); + +describe("applyOmpAcpModelSelection", () => { + effectIt.effect( + "writes the requested model through the model config option before other options", + () => + Effect.gen(function* () { + const calls: Array<{ + readonly configId: string; + readonly value: string | boolean; + }> = []; + + const runtime = { + getConfigOptions: Effect.succeed(ompConfigOptions), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + yield* applyOmpAcpModelSelection({ + runtime, + model: "openai/gpt-5.4", + selections: [{ id: "reasoning", value: "max" }], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([ + { configId: "model", value: "openai/gpt-5.4" }, + { configId: "thinking", value: "max" }, + ]); + }), + ); + + effectIt.effect("validates reasoning against the post-switch options of the new model", () => + Effect.gen(function* () { + // omp re-validates dependent selects per model: under `auto` the thinking + // select only accepts off/auto, so a `max` request valid for the previous + // model must be dropped rather than written and rejected by the CLI. + const autoModelOptions: ReadonlyArray = [ + ompConfigOptions[0]!, + ompConfigOptions[1]!, + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "auto", + options: [ + { value: "off", name: "Off" }, + { value: "auto", name: "Auto" }, + ], + }, + ]; + const calls: Array<{ + readonly configId: string; + readonly value: string | boolean; + }> = []; + + const runtime = { + getConfigOptions: Effect.sync(() => + calls.some((call) => call.configId === "model") ? autoModelOptions : ompConfigOptions, + ), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + yield* applyOmpAcpModelSelection({ + runtime, + model: "openai/gpt-5.4", + selections: [{ id: "reasoning", value: "max" }], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([{ configId: "model", value: "openai/gpt-5.4" }]); + }), + ); + + effectIt.effect("leaves the CLI's current model alone when no model is requested", () => + Effect.gen(function* () { + const calls: Array<{ + readonly configId: string; + readonly value: string | boolean; + }> = []; + + const runtime = { + getConfigOptions: Effect.succeed(ompConfigOptions), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + yield* applyOmpAcpModelSelection({ + runtime, + model: undefined, + selections: [{ id: "reasoning", value: "off" }], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([{ configId: "thinking", value: "off" }]); + }), + ); + + effectIt.effect("never writes a slug to a non-select model option", () => + Effect.gen(function* () { + const calls: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const runtime = { + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + category: "model", + type: "boolean" as const, + currentValue: false, + }, + ]), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + const applied = yield* applyOmpAcpModelSelection({ + runtime, + model: "openai/gpt-5.4", + selections: [], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([]); + expect(applied.model).toBeUndefined(); + }), + ); + + effectIt.effect("writes through when the session advertises no model option", () => + Effect.gen(function* () { + const calls: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const runtime = { + getConfigOptions: Effect.succeed([ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select" as const, + currentValue: "off", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, + ]), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + const applied = yield* applyOmpAcpModelSelection({ + runtime, + model: "openai/gpt-5.4", + selections: [], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([{ configId: "model", value: "openai/gpt-5.4" }]); + expect(applied.model).toBe("openai/gpt-5.4"); + }), + ); + + effectIt.effect("preserves the configured model when the slug is not advertised", () => + Effect.gen(function* () { + const calls: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + const runtime = { + getConfigOptions: Effect.succeed(ompConfigOptions), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ configId, value }); + }), + }; + + const applied = yield* applyOmpAcpModelSelection({ + runtime, + // A cross-provider default (the text-generation fallback shape) that + // omp's advertised catalog does not contain. + model: "anthropic/claude-fable-5", + selections: [], + mapError: ({ configId, cause }) => + `failed to set config option ${configId}: ${cause.message}`, + }); + + expect(calls).toEqual([]); + expect(applied.model).toBe("zhipu-coding-plan/glm-5.3"); + }), + ); +}); diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts new file mode 100644 index 000000000000..716a2d682fe8 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -0,0 +1,207 @@ +import { + type OmpSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + findOmpModelConfigOption, + findOmpModelConfigOptionAny, + flattenSessionConfigSelectOptions, + resolveOmpAcpConfigUpdates, +} from "../Layers/OmpProvider.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type OmpAcpRuntimeOmpSettings = Pick; + +export interface OmpAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly ompSettings: OmpAcpRuntimeOmpSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; + /** + * Whether to advertise form elicitation support (default true). Callers + * that register no elicitation handler (e.g. unattended text generation) + * must pass false: omp's uiContext.select then resolves immediately with + * undefined (a fast, clear refusal) instead of waiting on a channel nobody + * answers. + */ + readonly enableElicitation?: boolean; +} + +export interface OmpAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option"; + readonly configId?: string; +} + +/** + * RuntimeMode is a spawn-time concern for `omp acp`: approval behavior is + * selected through CLI flags (verified against omp/18.0.6), not through an + * in-session ACP mechanism. `always-ask` is passed explicitly for + * approval-required because bare `acp` inherits the user's own + * `tools.approvalMode` config, which may be `yolo` — Supervised must not + * silently inherit it. + */ +export function ompAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "auto-accept-edits": + return ["acp", "--approval-mode=write"]; + case "auto": + return ["acp", "--auto-approve"]; + case "full-access": + return ["acp", "--approval-mode=yolo"]; + case "approval-required": + default: + return ["acp", "--approval-mode=always-ask"]; + } +} + +export function buildOmpAcpSpawnInput( + ompSettings: OmpAcpRuntimeOmpSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: ompSettings?.binaryPath || "omp", + args: [...ompAcpSpawnArgs(runtimeMode)], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeOmpAcpRuntime = ( + input: OmpAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildOmpAcpSpawnInput( + input.ompSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), + // omp/18.0.6 advertises exactly one auth method ("Use existing local + // credentials"); credentials live under ~/.omp. + authMethodId: "agent", + // omp routes its second approval layer (extension wrapper, anything + // short of yolo) through session/elicitation, and only when the + // client declares form elicitation — undeclared reads as Deny. + ...(input.enableElicitation === false + ? {} + : { clientCapabilities: { elicitation: { form: {} } } }), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +interface OmpAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; +} + +/** + * Applies the requested model and provider options through omp's + * `session/set_config_option` mechanism. There is no static default model + * id for omp: when no model is requested, nothing is written and the CLI's + * current config value wins. + */ +export function applyOmpAcpModelSelection(input: { + readonly runtime: OmpAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: OmpAcpModelSelectionErrorContext) => E; +}): Effect.Effect<{ readonly model: string | undefined }, E> { + return Effect.gen(function* () { + const requestedModel = resolveOmpAcpBaseModelId(input.model); + let effectiveModel = requestedModel; + // Model first, then re-read config options: omp re-validates dependent + // selects per model (e.g. `thinking` accepts off/auto under `auto` but + // off/low/medium/high/max elsewhere), so validating against the + // pre-switch options writes values the session then rejects. + if (requestedModel !== undefined) { + const configOptions = yield* input.runtime.getConfigOptions; + // Existence probe without the select guard: ACP permits a boolean + // option named `model`, and a string slug must never be written to it. + const anyModelOption = findOmpModelConfigOptionAny(configOptions); + const modelOption = findOmpModelConfigOption(configOptions); + const modelConfigId = anyModelOption?.id ?? "model"; + // omp has no static default model id. Three cases: + // - no model option advertised at all: write through (the CLI's own + // default behavior; nothing to validate against); + // - a select model option that advertises the slug: write it; + // - anything else (unadvertised slug, or a non-select model option): + // preserve the session's configured model instead of failing or + // overwriting it with an unrelated cross-provider default. + const advertised = flattenSessionConfigSelectOptions(modelOption).map( + (option) => option.value, + ); + const shouldWrite = + anyModelOption === undefined || + (modelOption !== undefined && advertised.includes(requestedModel)); + if (shouldWrite) { + yield* input.runtime + .setConfigOption(modelConfigId, requestedModel) + .pipe( + Effect.mapError((cause) => + input.mapError({ cause, step: "set-config-option", configId: modelConfigId }), + ), + ); + } else { + // The write was skipped: report the model the session actually kept + // so callers stamp truthful state (turn events, session record). + const kept = anyModelOption; + effectiveModel = + kept?.type === "select" ? (kept.currentValue?.trim() ?? undefined) : undefined; + } + } + 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, + }), + ), + ); + } + return { model: effectiveModel }; + }); +} + +export function resolveOmpAcpBaseModelId(model: string | null | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) { + return undefined; + } + const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")) : trimmed; + return base.length > 0 ? base : undefined; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..1cd91d56df55 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OmpDriver, type OmpDriverEnv } from "./Drivers/OmpDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -38,6 +39,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | OmpDriverEnv | OpenCodeDriverEnv | AntigravityDriverEnv; @@ -51,6 +53,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && (filters.review === undefined || diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 2229a4f652c0..6ffb46d1d8e1 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -829,8 +829,8 @@ export const make = Effect.gen(function* () { viewer: string, ): boolean => { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && // Judged on the provider row rather than the entry, because the two absences mean diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..335b38a50874 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1040,6 +1040,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { grok: { enabled: false, }, + omp: { + enabled: false, + }, opencode: { enabled: false, serverUrl: "http://127.0.0.1:4096", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0b64d445adf8..7c902ad7db7d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -259,6 +259,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({ Schema.Struct({ cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + omp: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), }), ), @@ -287,6 +288,7 @@ function restoreUsedProviders( instance.enabled === undefined && (instance.driver === "cursor" || instance.driver === "grok" || + instance.driver === "omp" || instance.driver === "opencode") && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } @@ -306,6 +308,10 @@ function restoreUsedProviders( ...settings.providers.grok, enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), }, + omp: { + ...settings.providers.omp, + enabled: persisted.providers?.omp?.enabled ?? usedProviders.has("omp"), + }, opencode: { ...settings.providers.opencode, enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), @@ -362,6 +368,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = { ...DEFAULT_SERVER_SETTINGS.providers, cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, + omp: { ...DEFAULT_SERVER_SETTINGS.providers.omp, enabled: undefined }, opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, }, }; @@ -472,13 +479,13 @@ const make = Effect.gen(function* () { provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM projection_thread_sessions - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'grok', 'omp', 'opencode') UNION SELECT DISTINCT provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM provider_session_runtime - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'grok', 'omp', 'opencode') `.pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts new file mode 100644 index 000000000000..1c1ee4322a2d --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -0,0 +1,252 @@ +// This suite builds real mock-agent wrapper scripts and temp directories on +// disk, so direct node: imports are intentional. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vite-plus/test"; + +import { OmpSettings, ProviderInstanceId } from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { makeOmpTextGeneration } from "./OmpTextGeneration.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"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const OmpTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-omp-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeAcpAgentWrapper( + dir: string, + env: Record, + argvLogPath?: string, +): string { + const binDir = NodePath.join(dir, "bin"); + const agentPath = NodePath.join(binDir, "omp"); + NodeFS.mkdirSync(binDir, { recursive: true }); + NodeFS.writeFileSync( + agentPath, + [ + "#!/bin/sh", + "export T3_ACP_OMP_SHAPES=1", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + ...(argvLogPath ? [`printf '%s\\n' "$*" >> ${shellSingleQuote(argvLogPath)}`] : []), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec node ${JSON.stringify(mockAgentPath)} "$@"`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(agentPath, 0o755); + return agentPath; +} + +function withFakeAcpAgent( + env: Record, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + argvLogPath?: string, +) { + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const agentPath = makeAcpAgentWrapper(tempDir, env, argvLogPath); + const config = decodeOmpSettings({ binaryPath: agentPath }); + const textGeneration = yield* makeOmpTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function waitForFileContent(path: string): Effect.Effect { + return Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + 5_000; + for (;;) { + const result = yield* Effect.exit(Effect.sync(() => NodeFS.readFileSync(path, "utf8"))); + if (Exit.isSuccess(result)) { + return result.value; + } + { + if ((yield* Clock.currentTimeMillis) >= deadline) { + return yield* Effect.die(result.cause); + } + } + yield* Effect.sleep(25); + } + }); +} + +it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { + it.effect("spawns omp acp with --auto-approve for unattended background generation", () => { + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + const argvLogPath = NodePath.join(requestLogDir, "argv.txt"); + + return withFakeAcpAgent( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add generated commit message", + body: "- verify omp acp text generation", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/omp-text-generation", + stagedSummary: "M apps/server/src/textGeneration/OmpTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("omp"), "openai/gpt-5.4", [ + { id: "reasoning", value: "max" }, + ]), + }, + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify omp acp text generation"); + + // Unattended generation must never hit an approval prompt: the + // child is spawned with --auto-approve. + const argvLog = NodeFS.readFileSync(argvLogPath, "utf8").trim().split("\n"); + expect(argvLog).toEqual(["acp --auto-approve"]); + + const requests = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map( + (line) => JSON.parse(line) as { method?: string; params?: Record }, + ); + + // Text generation registers no elicitation handler, so the + // capability must not be advertised (omp would otherwise queue a + // select() nobody answers). + const initializeCapabilities = requests.find((request) => request.method === "initialize") + ?.params?.clientCapabilities; + expect( + typeof initializeCapabilities === "object" && + initializeCapabilities !== null && + "elicitation" in initializeCapabilities, + ).toBe(false); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "openai/gpt-5.4", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "thinking" && + request.params?.value === "max", + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); + + NodeFS.rmSync(requestLogDir, { recursive: true, force: true }); + }), + argvLogPath, + ); + }); + + it.effect("generates thread titles through omp ACP text generation", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: '"Trim reconnect spinner status after resume."', + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "zhipu-coding-plan/glm-5.3", + }, + }); + + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }), + ), + ); + + it.effect("closes the ACP child process after text generation completes", () => { + const exitLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-exit-log-"), + ); + const exitLogPath = NodePath.join(exitLogDir, "exit.log"); + + return withFakeAcpAgent( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Close runtime after generation", + body: "", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/omp-runtime-close", + stagedSummary: "M apps/server/src/textGeneration/OmpTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "openai/gpt-5.4", + }, + }); + + expect(generated.subject).toBe("Close runtime after generation"); + + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); + + NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }); +}); diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts new file mode 100644 index 000000000000..e8fcef48e701 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -0,0 +1,273 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type OmpSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { applyOmpAcpModelSelection, makeOmpAcpRuntime } from "../provider/acp/OmpAcpSupport.ts"; + +const OMP_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Build an Oh My Pi text-generation closure bound to a specific `OmpSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function* ( + ompSettings: OmpSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runOmpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeOmpAcpRuntime({ + ompSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + // Background text generation is unattended: nobody can answer an + // approval prompt, and this path registers no permission/elicitation + // handlers. `--auto-approve` keeps omp's wrapper layer and permission + // gate from ever pausing the one-shot generation. + runtimeMode: "auto", + // Without the elicitation capability omp's non-approval select() + // resolves immediately with undefined (fast, clear failure) instead + // of queueing onto a channel nobody answers until the 180s timeout. + enableElicitation: false, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + // No mode override: omp only has default/plan and the default mode is + // the right one for one-shot generation. The requested model passes + // through; when none is requested the CLI's current model wins. + yield* applyOmpAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId }) => + new TextGenerationError({ + operation, + detail: `Failed to set Oh My Pi ACP config option "${configId}" for text generation.`, + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(OMP_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Oh My Pi request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + 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, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OmpTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runOmpJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 199d0ba834d0..afb6d48b5671 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -704,13 +704,16 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); export const PiAgentIcon: Icon = ({ className, ...props }) => ( - - + - + ); diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index b1bb8ba9c74a..9fbd41394d30 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -9,6 +9,8 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { ModelListRow } from "./ModelListRow"; +import { Combobox } from "../ui/combobox"; import type { ModelEsque } from "./providerIconUtils"; function providerEntry(instanceId: string, driver: string) { @@ -99,7 +101,7 @@ describe("ProviderModelPicker", () => { }, ); - it.each(["codex", "claudeAgent", "cursor", "grok"])( + it.each(["codex", "claudeAgent", "cursor", "grok", "omp"])( "uses the first option label for a missing %s model", (driver) => { const markup = renderPicker({ @@ -161,4 +163,29 @@ describe("ProviderModelPicker", () => { expect(markup).toContain("h-3"); expect(markup).toContain("text-[7px]"); }); + + it("renders each model row with its provider and upstream label", () => { + const markup = renderToStaticMarkup( + + {}} + /> + , + ); + + expect(markup).toContain("Qwen3.8 Max"); + expect(markup).toContain("Oh My Pi · Alibaba Coding Plan"); + }); }); diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 44cd77b7e8d6..0b8562f683fc 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -268,7 +268,7 @@ describe("getComposerProviderState", () => { ); }); - it.each(["codex", "claudeAgent", "cursor", "grok"])( + it.each(["codex", "claudeAgent", "cursor", "grok", "omp"])( "does not preserve unknown options for a missing %s model", (provider) => { const state = getComposerProviderState({ diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..c1371e9973be 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -7,6 +7,7 @@ import { Icon, OpenAI, OpenCodeIcon, + PiAgentIcon, } from "../Icons"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -15,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("omp")]: PiAgentIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index c2fdde3d011f..971179e95911 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -380,8 +380,8 @@ export function matchesPullRequestFilters( filters: PullRequestListFilters, viewer?: string | null, ): boolean { - const labels = entry.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(entry.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) && (filters.review === undefined || diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index dc61f006fcab..f6cb57ecc3c0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -15,7 +15,14 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { Dialog } from "../ui/dialog"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; @@ -86,11 +93,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "ACP Registry", icon: ACPRegistryIcon, }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - }, ]; /** diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..0818b0f86b50 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -4,6 +4,7 @@ import { CodexSettings, CursorSettings, GrokSettings, + OmpSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; @@ -16,6 +17,7 @@ import { type Icon, OpenAI, OpenCodeIcon, + PiAgentIcon, } from "../Icons"; type ProviderSettingsSchema = { @@ -76,6 +78,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("omp"), + label: "Oh My Pi", + icon: PiAgentIcon, + badgeLabel: "Early Access", + settingsSchema: OmpSettings, + }, { value: ProviderDriverKind.make("antigravity"), label: "Antigravity", diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..5a7e0c7715ca 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -73,6 +73,7 @@ computer. | Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | | Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Oh My Pi | Install [Oh My Pi](https://github.com/can1357/oh-my-pi), then run `omp`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 3d37850c5e20..53b4a1cfdf98 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -29,3 +29,7 @@ Antigravity can still send native approval requests in **Full access**. It only approvals for actions that support them. See the [provider guides](./install.md#providers) for setup and provider-specific limits. + +Oh My Pi threads map the modes onto omp's approval flags: **Supervised** passes +`--approval-mode=always-ask`, **Auto-accept edits** passes `--approval-mode=write`, +**Auto** passes `--auto-approve`, and **Full access** passes `--approval-mode=yolo`. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index bce1a766bc9b..25308fbdacff 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,6 +148,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -220,5 +221,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [OMP_DRIVER_KIND]: "Oh My Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7d3cd2ceafe7..5aafaa3dab79 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,6 +7,7 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, + defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -545,9 +546,18 @@ describe("provider enabled defaults", () => { expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(false); expect(decoded.providers.grok.enabled).toBe(false); + expect(decoded.providers.omp.enabled).toBe(false); expect(decoded.providers.opencode.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(false); + 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("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..591f616d5037 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -789,6 +789,32 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; +export const OmpSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor, Grok and OpenCode): the binding is not + // yet stable enough to probe on every install. Users opt in from Settings. + 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 (omp) CLI binary.", + providerSettingsForm: { placeholder: "omp", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type OmpSettings = typeof OmpSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and Grok): the binding is not yet stable @@ -1042,6 +1068,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + omp: OmpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1088,7 +1115,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined @@ -1208,6 +1235,12 @@ const AntigravitySettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const OmpSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -1272,6 +1305,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + omp: Schema.optionalKey(OmpSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index e894359eccb5..07c5647da1a4 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -194,9 +194,9 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { if (!manifest) continue; const declared = { - ...(manifest.dependencies ?? {}), - ...(manifest.optionalDependencies ?? {}), - ...(manifest.peerDependencies ?? {}), + ...manifest.dependencies, + ...manifest.optionalDependencies, + ...manifest.peerDependencies, }; for (const dependency of Object.keys(declared)) { if (!isRuntimeExternal(dependency)) {