From 5766179dbc28f52968258ba44609ade391435441 Mon Sep 17 00:00:00 2001 From: omni Date: Wed, 9 Sep 2026 10:24:03 +0100 Subject: [PATCH 01/22] 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 + 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 +- 30 files changed, 6929 insertions(+), 42 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 e4fd848ab6e1..ad6cbdbe5324 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -51,7 +51,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 = @@ -69,8 +77,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"; @@ -116,6 +129,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 [ { @@ -300,29 +356,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 { @@ -551,6 +620,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); @@ -580,6 +652,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(), }; @@ -989,6 +1064,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 { 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 50f8649eaacb..980f2c76b720 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -263,6 +263,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) })), }), ), @@ -291,6 +292,7 @@ function restoreUsedProviders( instance.enabled === undefined && (instance.driver === "cursor" || instance.driver === "grok" || + instance.driver === "omp" || instance.driver === "opencode") && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } @@ -310,6 +312,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"), @@ -367,6 +373,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 }, }, }; @@ -592,13 +599,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 e4d41d53c3a8..5b5a1ac232a2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -758,14 +758,17 @@ 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 41e86f841ee4..19e0f3df4a29 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) { @@ -114,7 +116,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({ @@ -176,4 +178,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 a4ed171bd986..b1835f57e99d 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -97,6 +97,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 dfc0c448c858..10bcdb1a63d6 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -31,3 +31,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 0882aef51d05..bab837c58c67 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-6-astra"; @@ -221,5 +222,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 b090b47fba5b..7f48b4e74e77 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, @@ -662,9 +663,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 7b3715d704be..eca7ee35cb03 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -811,6 +811,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 @@ -1168,6 +1194,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({}))), @@ -1214,7 +1241,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 @@ -1335,6 +1362,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), @@ -1415,6 +1448,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 775c77fef1d8..3c6195bc1d12 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -189,9 +189,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)) { From d0c0a166e2167f17e1e1f220bc2e8a4382e9aafc Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 09:53:04 -0300 Subject: [PATCH 02/22] fix(omp): portable provider tests and truthful start-session model --- .../src/provider/Layers/OmpAdapter.test.ts | 58 ++++--- apps/server/src/provider/Layers/OmpAdapter.ts | 4 +- .../src/provider/Layers/OmpProvider.test.ts | 81 +++++----- apps/server/src/provider/acp/OmpAcpSupport.ts | 2 +- .../textGeneration/OmpTextGeneration.test.ts | 153 +++++++++--------- pnpm-lock.yaml | 4 +- 6 files changed, 144 insertions(+), 158 deletions(-) diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 97306a09af73..17aedbc3e3fb 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -41,6 +41,8 @@ import { parseOmpSubagentSpawns, selectOmpPermissionOptionId, } from "./OmpAdapter.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const decodeOmpSettings = Schema.decodeSync(OmpSettings); // Test-local service tag so the rest of the file can keep using `yield* OmpAdapter`. @@ -50,26 +52,26 @@ class OmpAdapter extends Context.Service()( 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; +// Stopping a session kills the agent with SIGTERM; Windows terminates the +// process instead, so the mock never sees a signal to log. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; 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; + return writeFakeCli({ + directory: dir, + name: "fake-omp", + env: { T3_ACP_OMP_SHAPES: "1", ...extraEnv }, + source: execScriptSource({ + scriptPath: mockAgentPath, + ...(options?.initialDelaySeconds === undefined + ? {} + : { delayMs: Math.round(options.initialDelaySeconds * 1000) }), + }), + }); } async function makeProbeWrapper( @@ -78,20 +80,16 @@ async function makeProbeWrapper( 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; + return writeFakeCli({ + directory: dir, + name: "fake-omp", + env: { + T3_ACP_OMP_SHAPES: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + ...extraEnv, + }, + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); } async function readArgvLog(filePath: string) { @@ -519,7 +517,7 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { }), ); - it.effect("closes the ACP child process when a session stops", () => + it.effect.skipIf(windowsHost)("closes the ACP child process when a session stops", () => Effect.gen(function* () { const adapter = yield* OmpAdapter; const settings = yield* ServerSettingsService; @@ -551,7 +549,7 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { }), ); - it.effect( + it.effect.skipIf(windowsHost)( "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index 970732aae50a..faa8d67631f1 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -1032,7 +1032,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ), ); - yield* applyRequestedSessionConfiguration({ + const startConfiguration = yield* applyRequestedSessionConfiguration({ runtime: acp, runtimeMode: input.runtimeMode, interactionMode: undefined, @@ -1048,7 +1048,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv status: "ready", runtimeMode: input.runtimeMode, cwd, - model: ompModelSelection?.model, + model: startConfiguration.model ?? resolveOmpAcpBaseModelId(ompModelSelection?.model), threadId: input.threadId, resumeCursor: { schemaVersion: OMP_RESUME_VERSION, diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts index 8ad2b89914f9..a109d7d5502d 100644 --- a/apps/server/src/provider/Layers/OmpProvider.test.ts +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -20,6 +20,8 @@ import { getOmpFallbackModels, resolveOmpAcpConfigUpdates, } from "./OmpProvider.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const node = ( effect: Effect.Effect< @@ -54,47 +56,38 @@ 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; + return writeFakeCli({ + directory: dir, + name: "fake-omp", + env: { T3_ACP_OMP_SHAPES: "1", ...extraEnv }, + source: execScriptSource({ scriptPath: mockAgentPath }), + }); }); 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; + return writeFakeCli({ + directory: dir, + name: "fake-omp", + env: { T3_ACP_OMP_SHAPES: "1" }, + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("omp/18.0.6\\n");', + " process.exit(0);", + "}", + execScriptSource({ scriptPath: mockAgentPath }), + ].join("\n"), + }); }); const waitForFileContent = Effect.fn("waitForFileContent")(function* ( @@ -391,23 +384,27 @@ describe("discoverOmpModelsViaAcp", () => { }), ); - effectIt.live("closes the ACP probe runtime after discovery completes", () => - Effect.gen(function* () { - const { exitLogPath, wrapperPath } = yield* node( - makeExitLogFixture("omp-provider-exit-log-"), - ); + // Stopping the probe kills the agent with SIGTERM; Windows terminates the + // process instead, so the mock never sees a signal to log. + effectIt.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "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: [], - }), - ); + yield* node( + discoverOmpModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }), + ); - const exitLog = yield* node(waitForFileContent(exitLogPath)); - expect(exitLog).toContain("SIGTERM"); - }), + const exitLog = yield* node(waitForFileContent(exitLogPath)); + expect(exitLog).toContain("SIGTERM"); + }), ); }); diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index 716a2d682fe8..2dce9cf53a00 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -202,6 +202,6 @@ export function resolveOmpAcpBaseModelId(model: string | null | undefined): stri if (!trimmed) { return undefined; } - const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")) : trimmed; + const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")).trim() : trimmed; return base.length > 0 ? base : undefined; } diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts index 1c1ee4322a2d..e6983ff94f46 100644 --- a/apps/server/src/textGeneration/OmpTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -5,12 +5,11 @@ 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 NodeFSP from "node:fs/promises"; 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"; @@ -21,15 +20,14 @@ import { OmpSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeOmpTextGeneration } from "./OmpTextGeneration.ts"; +import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + 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)); @@ -39,27 +37,16 @@ function makeAcpAgentWrapper( 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; + return writeFakeCli({ + directory: NodePath.join(dir, "bin"), + name: "omp", + env: { T3_ACP_OMP_SHAPES: "1", ...env }, + source: execScriptSource({ + scriptPath: mockAgentPath, + expectedArgs: ["acp"], + ...(argvLogPath === undefined ? {} : { argvLogPath }), + }), + }); } function withFakeAcpAgent( @@ -81,22 +68,18 @@ function withFakeAcpAgent( }).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); - } +async function waitForFileContent(filePath: string, attempts = 400): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const raw = await NodeFSP.readFile(filePath, "utf8"); + if (raw.trim().length > 0) { + return raw; } - yield* Effect.sleep(25); - } - }); + } catch {} + // Each attempt awaits real fs I/O, which yields to the event loop and + // lets the exiting child flush its log — no wall-clock timer needed. + } + throw new Error(`Timed out waiting for file content at ${filePath}`); } it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { @@ -135,8 +118,11 @@ it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { // 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 argvLog = NodeFS.readFileSync(argvLogPath, "utf8") + .trim() + .split("\n") + .map((line) => line.split("\t")); + expect(argvLog).toEqual([["acp", "--auto-approve"]]); const requests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() @@ -212,41 +198,46 @@ it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { ), ); - 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 }); - }), - ); - }); + // Windows terminates the child instead of signalling it, so the mock + // agent never reaches its exit handler and writes no exit log. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "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* Effect.promise(() => waitForFileContent(exitLogPath)); + expect(exitLog).toContain("exit:0"); + + NodeFS.rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }, + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a37d9bf6fa4..1b13c27bcbe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -774,7 +774,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) + version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -16446,7 +16446,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): + alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) From 93005265c15fb4c9a5be6cef38f6cc1a3512ddbd Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 10:08:09 -0300 Subject: [PATCH 03/22] fix(desktop): extract electron runtime without python3 on windows --- apps/desktop/scripts/ensure-electron-runtime.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index b8b8254c9b3c..57dd56c3491b 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -129,6 +129,16 @@ function installElectronRuntime(electronDir, version) { ]); if (hostPlatform === "darwin") { runChecked("ditto", ["-x", "-k", zipPath, NodePath.join(electronDir, "dist")]); + } else if (hostPlatform === "win32") { + // Windows ships no python3; PowerShell's Expand-Archive is always present. + runChecked("powershell", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath ${JSON.stringify(zipPath)} -DestinationPath ${JSON.stringify( + NodePath.join(electronDir, "dist"), + )} -Force`, + ]); } else { runChecked("python3", [ "-c", From 7a018ae06d981240508eed41b72fec4364315ee1 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 11:01:00 -0300 Subject: [PATCH 04/22] feat(omp): discover skills for the $ picker and dispatch mentions --- apps/server/src/provider/Drivers/OmpDriver.ts | 32 ++++ .../provider/Drivers/OmpSkillDispatch.test.ts | 45 +++++ .../src/provider/Drivers/OmpSkillDispatch.ts | 44 +++++ .../src/provider/Drivers/OmpSkills.test.ts | 86 +++++++++ apps/server/src/provider/Drivers/OmpSkills.ts | 167 ++++++++++++++++++ .../src/provider/Layers/OmpAdapter.test.ts | 40 +++++ apps/server/src/provider/Layers/OmpAdapter.ts | 17 +- 7 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/Drivers/OmpSkillDispatch.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpSkillDispatch.ts create mode 100644 apps/server/src/provider/Drivers/OmpSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpSkills.ts diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts index 8d5fa71e8236..ecfa95ed3ee0 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -32,6 +32,7 @@ import { checkOmpProviderStatus, enrichOmpSnapshot, } from "../Layers/OmpProvider.ts"; +import { discoverOmpSkills } from "./OmpSkills.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -112,10 +113,15 @@ export const OmpDriver: ProviderDriver = { }); const effectiveConfig = { ...config, enabled } satisfies OmpSettings; + // Skills discovered per workspace. The adapter reads names from here to + // rewrite `$name` mentions, so a turn never spawns its own probe. + const skillNamesByCwd = new Map>(); + const adapter = yield* makeOmpAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, + resolveSkillNames: (cwd) => skillNamesByCwd.get(cwd) ?? new Set(), }); const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv); @@ -157,6 +163,31 @@ export const OmpDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (workspaceCwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverOmpSkills(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Oh My Pi skills for '${workspaceCwd}'`, + cause, + }), + ), + ), + ]).pipe( + Effect.tap(([, skills]) => + Effect.sync(() => { + skillNamesByCwd.set(workspaceCwd, new Set(skills.map((skill) => skill.name))); + }), + ), + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + ); return { instanceId, @@ -167,6 +198,7 @@ export const OmpDriver: ProviderDriver = { enabled, snapshot, adapter, + snapshotForCwd, textGeneration, } satisfies ProviderInstance; }), diff --git a/apps/server/src/provider/Drivers/OmpSkillDispatch.test.ts b/apps/server/src/provider/Drivers/OmpSkillDispatch.test.ts new file mode 100644 index 000000000000..e4147f35f639 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpSkillDispatch.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { rewriteOmpSkillMentions } from "./OmpSkillDispatch.ts"; + +const SKILLS = new Set(["tdd", "2spec", "code-review", "skill:weird"]); + +describe("rewriteOmpSkillMentions", () => { + it("leaves a prompt without a known mention untouched", () => { + expect(rewriteOmpSkillMentions("fix the build", SKILLS)).toBeUndefined(); + expect(rewriteOmpSkillMentions("echo $HOME then $unknown", SKILLS)).toBeUndefined(); + }); + + it("rewrites a mention in place and keeps the surrounding prose", () => { + expect(rewriteOmpSkillMentions("ok, now $tdd the parser", SKILLS)).toBe( + "ok, now /skill:tdd the parser", + ); + }); + + it("rewrites a mention that opens the prompt", () => { + expect(rewriteOmpSkillMentions("$code-review\nfocus on auth", SKILLS)).toBe( + "/skill:code-review\nfocus on auth", + ); + }); + + it("rewrites every mention, since omp has no one-command-per-message limit", () => { + expect(rewriteOmpSkillMentions("$code-review the diff, then $tdd the fix", SKILLS)).toBe( + "/skill:code-review the diff, then /skill:tdd the fix", + ); + }); + + it("dispatches a skill whose name begins with a digit", () => { + expect(rewriteOmpSkillMentions("use $2spec here", SKILLS)).toBe("use /skill:2spec here"); + }); + + it("keeps currency amounts and glued tokens as prose", () => { + const withCurrencyNames = new Set([...SKILLS, "20", "20k"]); + expect(rewriteOmpSkillMentions("pay $20 tomorrow", withCurrencyNames)).toBeUndefined(); + expect(rewriteOmpSkillMentions("budget is $20k", withCurrencyNames)).toBeUndefined(); + expect(rewriteOmpSkillMentions("cost is 5$tdd", SKILLS)).toBeUndefined(); + }); + + it("leaves the prompt alone when no skills were discovered", () => { + expect(rewriteOmpSkillMentions("$tdd now", new Set())).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpSkillDispatch.ts b/apps/server/src/provider/Drivers/OmpSkillDispatch.ts new file mode 100644 index 000000000000..cf7fc121c185 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpSkillDispatch.ts @@ -0,0 +1,44 @@ +/** + * OmpSkillDispatch — turns `$skill` mentions from the composer into the + * invocation omp actually runs. + * + * The composer inserts `$name` for every provider. omp has no `$` syntax: it + * exposes each discovered skill as a `/skill:` command and recognizes + * that token even when it sits inside ordinary prose, which was verified over + * ACP against omp/18.1.18 (`/skill:tdd` came back with the skill's content + * loaded). Unlike Claude Code there is no last-text-block rule and no + * one-command-per-message limit, so every known mention is rewritten in place + * and the surrounding prose is left untouched. + * + * A mention that names no discovered skill stays literal: `$HOME` in prose + * must not become a command. + * + * @module provider/Drivers/OmpSkillDispatch + */ + +/** + * Kept in sync with the composer's own skill-token regex + * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a + * dispatched skill are always the same set. + */ +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +/** + * Rewrite every `$name` mention of a discovered skill as `/skill:name`. + * Returns `undefined` when the prompt carries no known mention, in which case + * it must go out unchanged. + */ +export function rewriteOmpSkillMentions( + prompt: string, + skillNames: ReadonlySet, +): string | undefined { + if (skillNames.size === 0) return undefined; + let rewritten = false; + const result = prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => { + if (!skillNames.has(name)) return match; + rewritten = true; + return `${prefix}/skill:${name}`; + }); + return rewritten ? result : undefined; +} diff --git a/apps/server/src/provider/Drivers/OmpSkills.test.ts b/apps/server/src/provider/Drivers/OmpSkills.test.ts new file mode 100644 index 000000000000..4b82762a365d --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpSkills.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { decodeOmpSkillCommands } from "./OmpSkills.ts"; + +const frame = (commands: ReadonlyArray) => + // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. + JSON.stringify({ type: "available_commands_update", commands }); + +describe("decodeOmpSkillCommands", () => { + it("keeps only skill commands, sorted by name", () => { + const stdout = [ + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + JSON.stringify({ type: "ready", protocolVersion: 1 }), + frame([ + { name: "tdd", description: "Not a skill command." }, + { name: "skill:tdd", description: "Test-driven development." }, + { name: "skill:code-review", description: "Review changes." }, + { name: "skillful", description: "Toggle skill listing." }, + ]), + "", + ].join("\n"); + + expect(decodeOmpSkillCommands(stdout)).toEqual([ + { + name: "code-review", + description: "Review changes.", + path: "skill://code-review/SKILL.md", + enabled: true, + }, + { + name: "tdd", + description: "Test-driven development.", + path: "skill://tdd/SKILL.md", + enabled: true, + }, + ]); + }); + + it("reads the response payload of an explicit command request", () => { + const stdout = + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + JSON.stringify({ + type: "response", + command: "get_available_commands", + success: true, + data: { commands: [{ name: "skill:deploy" }] }, + }); + + expect(decodeOmpSkillCommands(stdout)).toEqual([ + { name: "deploy", path: "skill://deploy/SKILL.md", enabled: true }, + ]); + }); + + it("skips malformed lines, nameless entries and blank descriptions", () => { + const stdout = [ + "not json", + "null", + frame([ + { name: "skill:" }, + { name: "skill: " }, + "not-an-object", + { name: "skill:keep", description: " " }, + ]), + ].join("\n"); + + expect(decodeOmpSkillCommands(stdout)).toEqual([ + { name: "keep", path: "skill://keep/SKILL.md", enabled: true }, + ]); + }); + + it("keeps the last frame's entry when a skill is announced twice", () => { + const stdout = [ + frame([{ name: "skill:tdd", description: "First." }]), + frame([{ name: "skill:tdd", description: "Updated." }]), + ].join("\n"); + + expect(decodeOmpSkillCommands(stdout)).toEqual([ + { name: "tdd", description: "Updated.", path: "skill://tdd/SKILL.md", enabled: true }, + ]); + }); + + it("returns nothing when the output carries no command frame", () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + expect(decodeOmpSkillCommands(JSON.stringify({ type: "ready" }))).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpSkills.ts b/apps/server/src/provider/Drivers/OmpSkills.ts new file mode 100644 index 000000000000..b67bbf610d00 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpSkills.ts @@ -0,0 +1,167 @@ +/** + * OmpSkills — skill discovery for the `$` picker via omp's RPC mode. + * + * omp resolves skills through a layered pipeline (native `.omp` user/project + * roots, plugin packages, Claude/Codex/agents/opencode/github providers, + * managed auto-learn skills) with per-source toggles, ignore globs and + * name-collision precedence. Re-implementing that scan in T3 would drift from + * the runtime on every omp release, so the catalog is read from omp itself. + * + * `omp acp` does not advertise commands (`session/new` returns only + * `sessionId`, `configOptions` and `modes`, verified against omp/18.1.18), but + * RPC mode emits an `available_commands_update` frame at startup that carries + * one `skill:` command per discovered skill with its description. The + * probe therefore spawns `omp --mode rpc`, closes stdin immediately, and reads + * that frame: on stdin close RPC drains accepted commands, disposes the + * session and exits 0, so no request has to be written and no model is ever + * called. + * + * @module provider/Drivers/OmpSkills + */ +import type { OmpSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { collectStreamAsString } from "../providerSnapshot.ts"; + +/** + * Startup covers config load, skill discovery, extension load and MCP + * connection, so the budget is generous next to a filesystem scan. It still + * has to fail rather than hang: a workspace snapshot waits on it. + */ +const OMP_SKILLS_PROBE_TIMEOUT_MS = 45_000; +const SKILL_COMMAND_PREFIX = "skill:"; + +export class OmpSkillsProbeError extends Schema.TaggedError()( + "OmpSkillsProbeError", + { + stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]), + cwd: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.NullOr(Schema.Number)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + return `Oh My Pi skill discovery${location} was incomplete (${this.stage}).`; + } +} + +/** + * Map the `skill:` entries of an `available_commands_update` frame onto + * provider skills. `path` carries omp's own `skill://` URL: the runtime + * resolves a skill by name through several roots and the RPC command list does + * not report the file it came from, so there is no filesystem path to hand + * back. + */ +export function decodeOmpSkillCommands(stdout: string): ReadonlyArray { + const skillsByName = new Map(); + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + let frame: unknown; + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. + frame = JSON.parse(trimmedLine); + } catch { + continue; + } + if (typeof frame !== "object" || frame === null) continue; + const record = frame as Record; + const commands = + record.type === "available_commands_update" + ? record.commands + : record.type === "response" && record.command === "get_available_commands" + ? (record.data as Record | undefined)?.commands + : undefined; + if (!Array.isArray(commands)) continue; + for (const entry of commands) { + if (typeof entry !== "object" || entry === null) continue; + const command = entry as Record; + const commandName = typeof command.name === "string" ? command.name.trim() : ""; + if (!commandName.startsWith(SKILL_COMMAND_PREFIX)) continue; + const name = commandName.slice(SKILL_COMMAND_PREFIX.length).trim(); + if (name.length === 0) continue; + const description = typeof command.description === "string" ? command.description.trim() : ""; + skillsByName.set(name, { + name, + path: `skill://${name}/SKILL.md`, + enabled: true, + ...(description.length > 0 ? { description } : {}), + }); + } + } + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Spawn `omp --mode rpc` in `cwd` and map its startup command catalog onto + * provider skills. Project-scoped skills live under the workspace, so the cwd + * decides the result and every workspace needs its own probe. + */ +export const discoverOmpSkills = Effect.fn("discoverOmpSkills")(function* ( + ompSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +) { + const command = ompSettings.binaryPath || "omp"; + const probe = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand( + command, + // No session file, no language servers: the probe only needs the command + // catalog, and both would cost startup time and leave state behind. + ["--mode", "rpc", "--no-session", "--no-lsp"], + { env: environment }, + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + // Closing stdin is the exit signal: RPC mode keeps reading commands until + // stdin ends, so without this the process outlives the probe. + const [stdout, , exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + Stream.run(Stream.empty, child.stdin), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, exitCode }; + }).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new OmpSkillsProbeError({ + stage: "spawn", + ...(cwd ? { cwd } : {}), + cause, + }), + ), + Effect.timeoutOption(OMP_SKILLS_PROBE_TIMEOUT_MS), + ); + + if (Option.isNone(probe)) { + return yield* new OmpSkillsProbeError({ + stage: "timeout", + ...(cwd ? { cwd } : {}), + }); + } + const skills = decodeOmpSkillCommands(probe.value.stdout); + if (skills.length === 0 && probe.value.exitCode !== 0) { + return yield* new OmpSkillsProbeError({ + stage: "exit", + ...(cwd ? { cwd } : {}), + exitCode: probe.value.exitCode, + }); + } + return skills; +}); diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 17aedbc3e3fb..e3d3da36bb45 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -169,6 +169,7 @@ const makeOmpAdapterTestLayer = (instanceId?: ProviderInstanceId) => return yield* makeOmpAdapter(ompConfig, { ...(instanceId ? { instanceId } : {}), resolveSettings, + resolveSkillNames: () => new Set(["tdd"]), }); }), ).pipe( @@ -691,6 +692,45 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { }), ); + it.effect("rewrites a $skill mention as omp's /skill: command on the wire", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-skill-mention-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", + }); + yield* adapter.sendTurn({ + threadId, + // `$unknown` names no discovered skill, so it must stay prose. + input: "run $tdd on $unknown", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + const blocks = (promptRequest?.params as { prompt?: ReadonlyArray } | undefined) + ?.prompt; + assert.deepStrictEqual(blocks, [{ type: "text", text: "run /skill:tdd on $unknown" }]); + }), + ); + it.effect( "applies initial model and mode configuration during startSession and skips repeating it on first send", () => diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index faa8d67631f1..aabb49dcbd85 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -75,6 +75,7 @@ import { resolveOmpAcpBaseModelId, } from "../acp/OmpAcpSupport.ts"; import { type OmpAdapterShape } from "../Services/OmpAdapter.ts"; +import { rewriteOmpSkillMentions } from "../Drivers/OmpSkillDispatch.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -109,6 +110,14 @@ export interface OmpAdapterLiveOptions { * the latest snapshot so the closure isn't stale. */ readonly resolveSettings?: Effect.Effect; + /** + * Names of the skills discovered for a workspace, used to rewrite the + * composer's `$name` mentions into omp's `/skill:` commands. The + * driver serves this from the catalog its workspace snapshot already + * probed, so a turn never spawns a discovery process. Unknown names and an + * empty set leave the prompt untouched. + */ + readonly resolveSkillNames?: (cwd: string) => ReadonlySet; } interface PendingApproval { @@ -1265,7 +1274,13 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv const promptParts: Array = []; if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); + const promptText = input.input.trim(); + const sessionCwd = ctx.session.cwd; + const dispatchedPrompt = + options?.resolveSkillNames && sessionCwd + ? rewriteOmpSkillMentions(promptText, options.resolveSkillNames(sessionCwd)) + : undefined; + promptParts.push({ type: "text", text: dispatchedPrompt ?? promptText }); } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { From f6932533f3a0ca87c5bef1f8e0afbf365b379e2e Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 11:08:59 -0300 Subject: [PATCH 05/22] feat(omp): publish native slash commands in the composer menu --- .../src/provider/Drivers/OmpCommands.test.ts | 131 +++++++++++ .../src/provider/Drivers/OmpCommands.ts | 210 ++++++++++++++++++ apps/server/src/provider/Drivers/OmpDriver.ts | 19 +- .../src/provider/Drivers/OmpSkills.test.ts | 86 ------- apps/server/src/provider/Drivers/OmpSkills.ts | 167 -------------- 5 files changed, 354 insertions(+), 259 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OmpCommands.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpCommands.ts delete mode 100644 apps/server/src/provider/Drivers/OmpSkills.test.ts delete mode 100644 apps/server/src/provider/Drivers/OmpSkills.ts diff --git a/apps/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts new file mode 100644 index 000000000000..3795ef6887a0 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { decodeOmpCommandCatalog } from "./OmpCommands.ts"; + +const frame = (commands: ReadonlyArray) => + // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. + JSON.stringify({ type: "available_commands_update", commands }); + +describe("decodeOmpCommandCatalog", () => { + it("splits skills from slash commands, each sorted by name", () => { + const stdout = [ + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + JSON.stringify({ type: "ready", protocolVersion: 1 }), + frame([ + { name: "skill:tdd", description: "Test-driven development." }, + { name: "skill:code-review", description: "Review changes." }, + { name: "model", aliases: ["models"], description: "Show current model selection." }, + { name: "compact", description: "Compact the conversation." }, + { name: "skillful", description: "Toggle skill listing." }, + ]), + "", + ].join("\n"); + + const catalog = decodeOmpCommandCatalog(stdout); + + expect(catalog.skills).toEqual([ + { + name: "code-review", + description: "Review changes.", + path: "skill://code-review/SKILL.md", + enabled: true, + }, + { + name: "tdd", + description: "Test-driven development.", + path: "skill://tdd/SKILL.md", + enabled: true, + }, + ]); + expect(catalog.slashCommands).toEqual([ + { name: "compact", description: "Compact the conversation." }, + { name: "model", description: "Show current model selection." }, + { name: "skillful", description: "Toggle skill listing." }, + ]); + }); + + it("keeps a command's argument hint and folds subcommands into the parent", () => { + const stdout = frame([ + { + name: "security", + description: "Run security scans", + input: { hint: "" }, + subcommands: [ + { name: "plan", description: "Create a plan" }, + { name: "scan", description: "Start a scan" }, + ], + }, + ]); + + expect(decodeOmpCommandCatalog(stdout).slashCommands).toEqual([ + { + name: "security", + description: "Run security scans", + input: { hint: "" }, + }, + ]); + }); + + it("reads the response payload of an explicit command request", () => { + const stdout = + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + JSON.stringify({ + type: "response", + command: "get_available_commands", + success: true, + data: { commands: [{ name: "skill:deploy" }, { name: "share" }] }, + }); + + const catalog = decodeOmpCommandCatalog(stdout); + expect(catalog.skills).toEqual([ + { name: "deploy", path: "skill://deploy/SKILL.md", enabled: true }, + ]); + expect(catalog.slashCommands).toEqual([{ name: "share" }]); + }); + + it("skips malformed lines, nameless entries and blank descriptions", () => { + const stdout = [ + "not json", + "null", + frame([ + { name: "skill:" }, + { name: "skill: " }, + { name: " " }, + "not-an-object", + { name: "skill:keep", description: " " }, + { name: "todo", input: { hint: " " } }, + ]), + ].join("\n"); + + const catalog = decodeOmpCommandCatalog(stdout); + expect(catalog.skills).toEqual([ + { name: "keep", path: "skill://keep/SKILL.md", enabled: true }, + ]); + expect(catalog.slashCommands).toEqual([{ name: "todo" }]); + }); + + it("keeps the last frame's entry when a command is announced twice", () => { + const stdout = [ + frame([ + { name: "skill:tdd", description: "First." }, + { name: "model", description: "First." }, + ]), + frame([ + { name: "skill:tdd", description: "Updated." }, + { name: "model", description: "Updated." }, + ]), + ].join("\n"); + + const catalog = decodeOmpCommandCatalog(stdout); + expect(catalog.skills[0]?.description).toBe("Updated."); + expect(catalog.slashCommands[0]?.description).toBe("Updated."); + }); + + it("returns empty catalogs when the output carries no command frame", () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + expect(decodeOmpCommandCatalog(JSON.stringify({ type: "ready" }))).toEqual({ + skills: [], + slashCommands: [], + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpCommands.ts b/apps/server/src/provider/Drivers/OmpCommands.ts new file mode 100644 index 000000000000..17f4d96131d7 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -0,0 +1,210 @@ +/** + * OmpCommands — skill and slash-command discovery for omp's composer menus. + * + * omp resolves skills through a layered pipeline (native `.omp` user/project + * roots, plugin packages, Claude/Codex/agents/opencode/github providers, + * managed auto-learn skills) with per-source toggles, ignore globs and + * name-collision precedence, and registers its own builtin/custom slash + * commands on top. Re-implementing either scan in T3 would drift from the + * runtime on every omp release, so both catalogs are read from omp itself. + * + * `omp acp` does not advertise commands (`session/new` returns only + * `sessionId`, `configOptions` and `modes`, verified against omp/18.1.18), but + * RPC mode emits an `available_commands_update` frame at startup carrying + * every command: one `skill:` per discovered skill plus the regular + * commands. The probe therefore spawns `omp --mode rpc`, closes stdin + * immediately, and reads that frame: on stdin close RPC drains accepted + * commands, disposes the session and exits 0, so no request has to be written + * and no model is ever called. + * + * Both kinds reach omp as ordinary prompt text — `/tools` and `/skill:` + * were both verified to run over an ACP `session/prompt` — so discovery is + * only about offering them in the menus. + * + * @module provider/Drivers/OmpCommands + */ +import type { + OmpSettings, + ServerProviderSkill, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { collectStreamAsString } from "../providerSnapshot.ts"; + +/** + * Startup covers config load, skill discovery, extension load and MCP + * connection, so the budget is generous next to a filesystem scan. It still + * has to fail rather than hang: a workspace snapshot waits on it. + */ +const OMP_COMMANDS_PROBE_TIMEOUT_MS = 45_000; +const SKILL_COMMAND_PREFIX = "skill:"; + +export interface OmpCommandCatalog { + readonly skills: ReadonlyArray; + readonly slashCommands: ReadonlyArray; +} + +export class OmpCommandsProbeError extends Schema.TaggedError()( + "OmpCommandsProbeError", + { + stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]), + cwd: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.NullOr(Schema.Number)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + return `Oh My Pi command discovery${location} was incomplete (${this.stage}).`; + } +} + +function trimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function commandEntriesFromFrame(frame: Record): ReadonlyArray { + const commands = + frame.type === "available_commands_update" + ? frame.commands + : frame.type === "response" && frame.command === "get_available_commands" + ? (frame.data as Record | undefined)?.commands + : undefined; + return Array.isArray(commands) ? commands : []; +} + +/** + * Split an RPC `available_commands_update` frame into provider skills and + * slash commands. Skill `path` carries omp's own `skill://` URL: the runtime + * resolves a skill by name through several roots and the command list does not + * report the file it came from, so there is no filesystem path to hand back. + * + * Subcommands stay folded into their parent: T3's composer has no nested + * commands, and omp's parent entry already advertises them through its input + * hint (`/security `). + */ +export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { + const skillsByName = new Map(); + const commandsByName = new Map(); + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + let frame: unknown; + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. + frame = JSON.parse(trimmedLine); + } catch { + continue; + } + if (typeof frame !== "object" || frame === null) continue; + for (const entry of commandEntriesFromFrame(frame as Record)) { + if (typeof entry !== "object" || entry === null) continue; + const command = entry as Record; + const commandName = trimmedString(command.name); + if (commandName.length === 0) continue; + const description = trimmedString(command.description); + if (commandName.startsWith(SKILL_COMMAND_PREFIX)) { + const name = commandName.slice(SKILL_COMMAND_PREFIX.length).trim(); + if (name.length === 0) continue; + skillsByName.set(name, { + name, + path: `skill://${name}/SKILL.md`, + enabled: true, + ...(description.length > 0 ? { description } : {}), + }); + continue; + } + const hint = trimmedString((command.input as Record | undefined)?.hint); + commandsByName.set(commandName, { + name: commandName, + ...(description.length > 0 ? { description } : {}), + ...(hint.length > 0 ? { input: { hint } } : {}), + }); + } + } + return { + skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), + slashCommands: [...commandsByName.values()].sort((left, right) => + left.name.localeCompare(right.name), + ), + }; +} + +/** + * Spawn `omp --mode rpc` in `cwd` and map its startup catalog onto provider + * skills and slash commands. Project-scoped skills and commands live under the + * workspace, so the cwd decides the result and every workspace needs its own + * probe. + */ +export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")(function* ( + ompSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +) { + const command = ompSettings.binaryPath || "omp"; + const probe = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand( + command, + // No session file, no language servers: the probe only needs the command + // catalog, and both would cost startup time and leave state behind. + ["--mode", "rpc", "--no-session", "--no-lsp"], + { env: environment }, + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + // Closing stdin is the exit signal: RPC mode keeps reading commands until + // stdin ends, so without this the process outlives the probe. + const [stdout, , exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + Stream.run(Stream.empty, child.stdin), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, exitCode }; + }).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new OmpCommandsProbeError({ + stage: "spawn", + ...(cwd ? { cwd } : {}), + cause, + }), + ), + Effect.timeoutOption(OMP_COMMANDS_PROBE_TIMEOUT_MS), + ); + + if (Option.isNone(probe)) { + return yield* new OmpCommandsProbeError({ + stage: "timeout", + ...(cwd ? { cwd } : {}), + }); + } + const catalog = decodeOmpCommandCatalog(probe.value.stdout); + if ( + catalog.skills.length === 0 && + catalog.slashCommands.length === 0 && + probe.value.exitCode !== 0 + ) { + return yield* new OmpCommandsProbeError({ + stage: "exit", + ...(cwd ? { cwd } : {}), + exitCode: probe.value.exitCode, + }); + } + return catalog; +}); diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts index ecfa95ed3ee0..303119214941 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -32,7 +32,7 @@ import { checkOmpProviderStatus, enrichOmpSnapshot, } from "../Layers/OmpProvider.ts"; -import { discoverOmpSkills } from "./OmpSkills.ts"; +import { discoverOmpCommandCatalog } from "./OmpCommands.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -168,25 +168,32 @@ export const OmpDriver: ProviderDriver = { ? snapshot.getSnapshot : Effect.all([ snapshot.getSnapshot, - discoverOmpSkills(effectiveConfig, processEnv, workspaceCwd).pipe( + discoverOmpCommandCatalog(effectiveConfig, processEnv, workspaceCwd).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to discover Oh My Pi skills for '${workspaceCwd}'`, + detail: `Failed to discover Oh My Pi commands for '${workspaceCwd}'`, cause, }), ), ), ]).pipe( - Effect.tap(([, skills]) => + Effect.tap(([, catalog]) => Effect.sync(() => { - skillNamesByCwd.set(workspaceCwd, new Set(skills.map((skill) => skill.name))); + skillNamesByCwd.set( + workspaceCwd, + new Set(catalog.skills.map((skill) => skill.name)), + ); }), ), - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.map(([machineSnapshot, catalog]) => ({ + ...machineSnapshot, + skills: catalog.skills, + slashCommands: catalog.slashCommands, + })), ); return { diff --git a/apps/server/src/provider/Drivers/OmpSkills.test.ts b/apps/server/src/provider/Drivers/OmpSkills.test.ts deleted file mode 100644 index 4b82762a365d..000000000000 --- a/apps/server/src/provider/Drivers/OmpSkills.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { decodeOmpSkillCommands } from "./OmpSkills.ts"; - -const frame = (commands: ReadonlyArray) => - // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. - JSON.stringify({ type: "available_commands_update", commands }); - -describe("decodeOmpSkillCommands", () => { - it("keeps only skill commands, sorted by name", () => { - const stdout = [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. - JSON.stringify({ type: "ready", protocolVersion: 1 }), - frame([ - { name: "tdd", description: "Not a skill command." }, - { name: "skill:tdd", description: "Test-driven development." }, - { name: "skill:code-review", description: "Review changes." }, - { name: "skillful", description: "Toggle skill listing." }, - ]), - "", - ].join("\n"); - - expect(decodeOmpSkillCommands(stdout)).toEqual([ - { - name: "code-review", - description: "Review changes.", - path: "skill://code-review/SKILL.md", - enabled: true, - }, - { - name: "tdd", - description: "Test-driven development.", - path: "skill://tdd/SKILL.md", - enabled: true, - }, - ]); - }); - - it("reads the response payload of an explicit command request", () => { - const stdout = - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. - JSON.stringify({ - type: "response", - command: "get_available_commands", - success: true, - data: { commands: [{ name: "skill:deploy" }] }, - }); - - expect(decodeOmpSkillCommands(stdout)).toEqual([ - { name: "deploy", path: "skill://deploy/SKILL.md", enabled: true }, - ]); - }); - - it("skips malformed lines, nameless entries and blank descriptions", () => { - const stdout = [ - "not json", - "null", - frame([ - { name: "skill:" }, - { name: "skill: " }, - "not-an-object", - { name: "skill:keep", description: " " }, - ]), - ].join("\n"); - - expect(decodeOmpSkillCommands(stdout)).toEqual([ - { name: "keep", path: "skill://keep/SKILL.md", enabled: true }, - ]); - }); - - it("keeps the last frame's entry when a skill is announced twice", () => { - const stdout = [ - frame([{ name: "skill:tdd", description: "First." }]), - frame([{ name: "skill:tdd", description: "Updated." }]), - ].join("\n"); - - expect(decodeOmpSkillCommands(stdout)).toEqual([ - { name: "tdd", description: "Updated.", path: "skill://tdd/SKILL.md", enabled: true }, - ]); - }); - - it("returns nothing when the output carries no command frame", () => { - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. - expect(decodeOmpSkillCommands(JSON.stringify({ type: "ready" }))).toEqual([]); - }); -}); diff --git a/apps/server/src/provider/Drivers/OmpSkills.ts b/apps/server/src/provider/Drivers/OmpSkills.ts deleted file mode 100644 index b67bbf610d00..000000000000 --- a/apps/server/src/provider/Drivers/OmpSkills.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * OmpSkills — skill discovery for the `$` picker via omp's RPC mode. - * - * omp resolves skills through a layered pipeline (native `.omp` user/project - * roots, plugin packages, Claude/Codex/agents/opencode/github providers, - * managed auto-learn skills) with per-source toggles, ignore globs and - * name-collision precedence. Re-implementing that scan in T3 would drift from - * the runtime on every omp release, so the catalog is read from omp itself. - * - * `omp acp` does not advertise commands (`session/new` returns only - * `sessionId`, `configOptions` and `modes`, verified against omp/18.1.18), but - * RPC mode emits an `available_commands_update` frame at startup that carries - * one `skill:` command per discovered skill with its description. The - * probe therefore spawns `omp --mode rpc`, closes stdin immediately, and reads - * that frame: on stdin close RPC drains accepted commands, disposes the - * session and exits 0, so no request has to be written and no model is ever - * called. - * - * @module provider/Drivers/OmpSkills - */ -import type { OmpSettings, ServerProviderSkill } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; - -import { collectStreamAsString } from "../providerSnapshot.ts"; - -/** - * Startup covers config load, skill discovery, extension load and MCP - * connection, so the budget is generous next to a filesystem scan. It still - * has to fail rather than hang: a workspace snapshot waits on it. - */ -const OMP_SKILLS_PROBE_TIMEOUT_MS = 45_000; -const SKILL_COMMAND_PREFIX = "skill:"; - -export class OmpSkillsProbeError extends Schema.TaggedError()( - "OmpSkillsProbeError", - { - stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]), - cwd: Schema.optional(Schema.String), - exitCode: Schema.optional(Schema.NullOr(Schema.Number)), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message() { - const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; - return `Oh My Pi skill discovery${location} was incomplete (${this.stage}).`; - } -} - -/** - * Map the `skill:` entries of an `available_commands_update` frame onto - * provider skills. `path` carries omp's own `skill://` URL: the runtime - * resolves a skill by name through several roots and the RPC command list does - * not report the file it came from, so there is no filesystem path to hand - * back. - */ -export function decodeOmpSkillCommands(stdout: string): ReadonlyArray { - const skillsByName = new Map(); - for (const line of stdout.split("\n")) { - const trimmedLine = line.trim(); - if (trimmedLine.length === 0) continue; - let frame: unknown; - try { - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. - frame = JSON.parse(trimmedLine); - } catch { - continue; - } - if (typeof frame !== "object" || frame === null) continue; - const record = frame as Record; - const commands = - record.type === "available_commands_update" - ? record.commands - : record.type === "response" && record.command === "get_available_commands" - ? (record.data as Record | undefined)?.commands - : undefined; - if (!Array.isArray(commands)) continue; - for (const entry of commands) { - if (typeof entry !== "object" || entry === null) continue; - const command = entry as Record; - const commandName = typeof command.name === "string" ? command.name.trim() : ""; - if (!commandName.startsWith(SKILL_COMMAND_PREFIX)) continue; - const name = commandName.slice(SKILL_COMMAND_PREFIX.length).trim(); - if (name.length === 0) continue; - const description = typeof command.description === "string" ? command.description.trim() : ""; - skillsByName.set(name, { - name, - path: `skill://${name}/SKILL.md`, - enabled: true, - ...(description.length > 0 ? { description } : {}), - }); - } - } - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); -} - -/** - * Spawn `omp --mode rpc` in `cwd` and map its startup command catalog onto - * provider skills. Project-scoped skills live under the workspace, so the cwd - * decides the result and every workspace needs its own probe. - */ -export const discoverOmpSkills = Effect.fn("discoverOmpSkills")(function* ( - ompSettings: Pick, - environment: NodeJS.ProcessEnv = process.env, - cwd?: string, -) { - const command = ompSettings.binaryPath || "omp"; - const probe = yield* Effect.gen(function* () { - const spawnCommand = yield* resolveSpawnCommand( - command, - // No session file, no language servers: the probe only needs the command - // catalog, and both would cost startup time and leave state behind. - ["--mode", "rpc", "--no-session", "--no-lsp"], - { env: environment }, - ); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - ...(cwd ? { cwd } : {}), - env: environment, - shell: spawnCommand.shell, - }), - ); - // Closing stdin is the exit signal: RPC mode keeps reading commands until - // stdin ends, so without this the process outlives the probe. - const [stdout, , exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - Stream.run(Stream.empty, child.stdin), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - return { stdout, exitCode }; - }).pipe( - Effect.scoped, - Effect.mapError( - (cause) => - new OmpSkillsProbeError({ - stage: "spawn", - ...(cwd ? { cwd } : {}), - cause, - }), - ), - Effect.timeoutOption(OMP_SKILLS_PROBE_TIMEOUT_MS), - ); - - if (Option.isNone(probe)) { - return yield* new OmpSkillsProbeError({ - stage: "timeout", - ...(cwd ? { cwd } : {}), - }); - } - const skills = decodeOmpSkillCommands(probe.value.stdout); - if (skills.length === 0 && probe.value.exitCode !== 0) { - return yield* new OmpSkillsProbeError({ - stage: "exit", - ...(cwd ? { cwd } : {}), - exitCode: probe.value.exitCode, - }); - } - return skills; -}); From 992a93c47a4db7254a194350a304033d1821de10 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 11:32:41 -0300 Subject: [PATCH 06/22] feat(omp): context meter, compaction, usage limits, auth, self-update, workspace snapshots --- apps/server/scripts/acp-mock-agent.ts | 43 +- .../src/provider/Drivers/OmpDriver.test.ts | 267 ++++++++++++ apps/server/src/provider/Drivers/OmpDriver.ts | 98 ++++- .../provider/Drivers/OmpMaintenance.test.ts | 65 +++ .../src/provider/Drivers/OmpMaintenance.ts | 159 +++++++ .../src/provider/Drivers/OmpUsage.test.ts | 335 ++++++++++++++ apps/server/src/provider/Drivers/OmpUsage.ts | 411 ++++++++++++++++++ .../src/provider/Layers/OmpAdapter.test.ts | 203 +++++++++ apps/server/src/provider/Layers/OmpAdapter.ts | 175 ++++++++ .../src/provider/Layers/OmpProvider.test.ts | 190 ++++++++ .../server/src/provider/Layers/OmpProvider.ts | 31 +- 11 files changed, 1946 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OmpDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpMaintenance.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpMaintenance.ts create mode 100644 apps/server/src/provider/Drivers/OmpUsage.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpUsage.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index ad6cbdbe5324..78ca4b95a3a7 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -58,6 +58,15 @@ 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"; +// omp reports context occupancy through `usage_update` and the finished +// turn's token split through the `session/prompt` response; both are +// off by default so the suites sharing this agent keep their event counts. +const emitUsageUpdate = process.env.T3_ACP_EMIT_USAGE_UPDATE === "1"; +const usageUpdateSize = Number(process.env.T3_ACP_USAGE_UPDATE_SIZE ?? "1000000"); +const usageUpdateUsed = Number(process.env.T3_ACP_USAGE_UPDATE_USED ?? "39451"); +const emitPromptResponseUsage = process.env.T3_ACP_EMIT_PROMPT_RESPONSE_USAGE === "1"; +// omp's todo_auto_clear maps to a `plan` update with zero entries. +const emitEmptyPlanAfterPlan = process.env.T3_ACP_EMIT_EMPTY_PLAN_AFTER_PLAN === "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"; @@ -1483,6 +1492,25 @@ const program = Effect.gen(function* () { }, }); + if (emitEmptyPlanAfterPlan) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "plan", entries: [] }, + }); + } + + if (emitUsageUpdate) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "usage_update", + size: usageUpdateSize, + used: usageUpdateUsed, + cost: { amount: 0.4466925, currency: "USD" }, + }, + }); + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { @@ -1491,7 +1519,20 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + return { + stopReason: "end_turn", + ...(emitPromptResponseUsage + ? { + usage: { + inputTokens: 1_234, + outputTokens: 567, + totalTokens: 1_801, + cachedReadTokens: 890, + cachedWriteTokens: 12, + }, + } + : {}), + }; }), ); diff --git a/apps/server/src/provider/Drivers/OmpDriver.test.ts b/apps/server/src/provider/Drivers/OmpDriver.test.ts new file mode 100644 index 000000000000..5039ab4e8719 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.test.ts @@ -0,0 +1,267 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { createProviderVersionAdvisory } from "../providerMaintenance.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { OmpDriver } from "./OmpDriver.ts"; + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-omp-driver-maintenance-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected omp HTTP request in driver test")), + ), + ), +); + +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)); +}); + +const catalogCommands = [ + { name: "skill:deploy", description: "Deploy the app" }, + { name: "share", description: "Share the session" }, +]; + +/** + * Fake omp answering every subcommand the driver probes: `--version` for the + * status check, `update --check` for maintenance, `--mode rpc` for the + * command catalog, and `acp` delegated to the mock agent. The ACP shapes flip + * through a flag file so a refresh can publish a changed catalog. + */ +function fakeOmpSource(input: { + readonly mockAgentPath: string; + readonly checkOutput: string; + readonly ompShapesEnv: string; +}): string { + return [ + 'import { existsSync } from "node:fs";', + 'import { pathToFileURL } from "node:url";', + "const args = process.argv.slice(2);", + 'if (args[0] === "--version") {', + ' process.stdout.write("omp/18.1.18\\n");', + " process.exit(0);", + "}", + 'if (args[0] === "update" && args[1] === "--check") {', + ` process.stdout.write(${JSON.stringify(input.checkOutput)});`, + " process.exit(0);", + "}", + 'if (args[0] === "--mode") {', + ` process.stdout.write(${JSON.stringify(`${JSON.stringify({ type: "available_commands_update", commands: catalogCommands })}\n`)});`, + " process.exit(0);", + "}", + 'if (args[0] === "acp") {', + ` ${input.ompShapesEnv}`, + ` await import(pathToFileURL(${JSON.stringify(input.mockAgentPath)}).href);`, + "} else {", + ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);', + " process.exit(11);", + "}", + "", + ].join("\n"); +} + +const makeFakeOmp = Effect.fn("makeFakeOmp")(function* (options: { + readonly prefix: string; + readonly checkOutput: string; + readonly ompShapesEnv?: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const mockAgentPath = yield* resolveMockAgentPath(); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: options.prefix }); + return writeFakeCli({ + directory, + name: "fake-omp", + source: fakeOmpSource({ + mockAgentPath, + checkOutput: options.checkOutput, + ompShapesEnv: options.ompShapesEnv ?? 'process.env.T3_ACP_OMP_SHAPES = "1";', + }), + }); +}); + +const createTestInstance = ( + instanceId: string, + input: { readonly binaryPath: string; readonly enabled: boolean }, +) => + OmpDriver.create({ + instanceId: ProviderInstanceId.make(instanceId), + displayName: "omp test", + enabled: input.enabled, + environment: [], + config: { ...OmpDriver.defaultConfig(), binaryPath: input.binaryPath }, + }); + +it.layer(testLayer)("OmpDriver", (it) => { + it.effect("advertises omp's own update command with the --check latest version", () => + Effect.gen(function* () { + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-update-", + checkOutput: "Current version: 18.1.18\nNew version available: 18.1.21\n", + }); + const instance = yield* createTestInstance("omp-update-check", { + binaryPath: fakePath, + enabled: false, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.update).toMatchObject({ args: ["update"], lockKey: "omp" }); + expect(capabilities.update?.executable).toContain("fake-omp"); + expect(capabilities.update?.command).toContain("update"); + expect(capabilities.latestVersion).toBe("18.1.21"); + expect( + createProviderVersionAdvisory({ + driver: OmpDriver.driverKind, + currentVersion: "18.1.18", + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ status: "behind_latest", canUpdate: true }); + }).pipe(Effect.scoped), + ); + + it.effect("reports current when update --check announces no new version", () => + Effect.gen(function* () { + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-current-", + checkOutput: "Current version: 18.1.21\nAlready up to date.\n", + }); + const instance = yield* createTestInstance("omp-update-current", { + binaryPath: fakePath, + enabled: false, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.update).toMatchObject({ args: ["update"], lockKey: "omp" }); + expect(capabilities.latestVersion).toBe("18.1.21"); + expect( + createProviderVersionAdvisory({ + driver: OmpDriver.driverKind, + currentVersion: "18.1.21", + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ status: "current", canUpdate: true }); + }).pipe(Effect.scoped), + ); + + it.effect("stays manual-only when the configured executable does not exist", () => + Effect.gen(function* () { + const instance = yield* createTestInstance("omp-update-missing", { + binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-omp-missing", "omp"), + enabled: false, + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe(Effect.scoped), + ); + + it.effect("records a workspace snapshot per cwd and keeps earlier workspaces", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-workspace-", + checkOutput: "Current version: 18.1.18\n", + }); + const instance = yield* createTestInstance("omp-workspace", { + binaryPath: fakePath, + enabled: true, + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) + return yield* Effect.die("OmpDriver does not expose workspace snapshots."); + const workspaceA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-workspace-a-" }); + const workspaceB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-workspace-b-" }); + const first = yield* snapshotForCwd(workspaceA); + expect(first.skills.map((skill) => skill.name)).toEqual(["deploy"]); + expect(first.slashCommands.map((command) => command.name)).toEqual(["share"]); + expect(first.workspaceSnapshots?.map((snapshot) => snapshot.cwd)).toEqual([workspaceA]); + expect(first.workspaceSnapshots?.[0]?.skills.map((skill) => skill.name)).toEqual(["deploy"]); + expect(first.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name)).toEqual([ + "share", + ]); + + const second = yield* snapshotForCwd(workspaceB); + expect(second.workspaceSnapshots?.map((snapshot) => snapshot.cwd)).toEqual([ + workspaceA, + workspaceB, + ]); + + const third = yield* snapshotForCwd(workspaceA); + expect(third.workspaceSnapshots?.map((snapshot) => snapshot.cwd)).toEqual([ + workspaceB, + workspaceA, + ]); + }).pipe(Effect.scoped), + ); + + it.effect("refreshModels re-probes and publishes a changed catalog", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-driver-refresh-" }); + const shapesFlagPath = path.join(root, "omp-shapes"); + const mockAgentPath = yield* resolveMockAgentPath(); + const fakePath = writeFakeCli({ + directory: path.join(root, "bin"), + name: "fake-omp", + source: fakeOmpSource({ + mockAgentPath, + checkOutput: "Current version: 18.1.18\n", + // @effect-diagnostics-next-line preferSchemaOverJson:off - quoting a path into the fake CLI source. + ompShapesEnv: `if (existsSync(${JSON.stringify(shapesFlagPath)})) { process.env.T3_ACP_OMP_SHAPES = "1"; } else { delete process.env.T3_ACP_OMP_SHAPES; }`, + }), + }); + const instance = yield* createTestInstance("omp-refresh", { + binaryPath: fakePath, + enabled: true, + }); + // The managed snapshot probes in the background, so await one refresh + // for the baseline catalog instead of racing the initial probe. + const baseline = yield* instance.snapshot.refresh; + const before = baseline.models.map((model) => model.slug); + expect([...before].sort()).toEqual( + [ + "composer-2", + "composer-2[fast=true]", + "default", + "gpt-5.3-codex[reasoning=medium,fast=false]", + ].sort(), + ); + + yield* fs.writeFileString(shapesFlagPath, "omp\n"); + const refresh = instance.refreshModels; + if (!refresh) return yield* Effect.die("OmpDriver does not expose model refresh."); + yield* refresh(); + + const after = (yield* instance.snapshot.getSnapshot).models.map((model) => model.slug); + expect([...after].sort()).toEqual( + ["anthropic/claude-opus-4-6", "openai/gpt-5.4", "zhipu-coding-plan/glm-5.3"].sort(), + ); + expect(after).not.toEqual(before); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts index 303119214941..1ccb04c5c4f7 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -12,8 +12,14 @@ * * @module provider/Drivers/OmpDriver */ -import { OmpSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { + OmpSettings, + ProviderDriverKind, + type ServerProvider, + type ServerProviderWorkspaceSnapshot, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -42,21 +48,20 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + makeCachedProviderMaintenanceResolution, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { makeOmpMaintenanceResolver, appendOmpWorkspaceSnapshot } from "./OmpMaintenance.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 @@ -97,6 +102,8 @@ export const OmpDriver: ProviderDriver = { Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -113,10 +120,23 @@ export const OmpDriver: ProviderDriver = { }); const effectiveConfig = { ...config, enabled } satisfies OmpSettings; + // omp is its own updater (`omp update`, latest from `omp update + // --check`), so the resolved executable is its own update command. A + // binary that cannot be resolved stays manual-only: nothing to update, + // not "whatever is on PATH". + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(makeOmpMaintenanceResolver(), { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ), + ); // Skills discovered per workspace. The adapter reads names from here to // rewrite `$name` mentions, so a turn never spawns its own probe. const skillNamesByCwd = new Map>(); - const adapter = yield* makeOmpAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -133,7 +153,7 @@ export const OmpDriver: ProviderDriver = { const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -143,15 +163,19 @@ export const OmpDriver: ProviderDriver = { // 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, - }), + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichOmpSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + stampIdentity, + httpClient, + }), + ), + ), }).pipe( Effect.mapError( (cause) => @@ -163,6 +187,10 @@ export const OmpDriver: ProviderDriver = { }), ), ); + // Per-workspace command catalogs. The composer only offers a workspace's + // menus once its snapshot is recorded, so every refresh retains the + // workspaces visited earlier in the session (Antigravity precedent). + let retainedWorkspaceSnapshots: ReadonlyArray = []; const snapshotForCwd = (workspaceCwd: string) => !effectiveConfig.enabled ? snapshot.getSnapshot @@ -189,13 +217,34 @@ export const OmpDriver: ProviderDriver = { ); }), ), - Effect.map(([machineSnapshot, catalog]) => ({ - ...machineSnapshot, - skills: catalog.skills, - slashCommands: catalog.slashCommands, - })), + Effect.flatMap(([machineSnapshot, catalog]) => + Effect.map(DateTime.now, (now) => { + retainedWorkspaceSnapshots = appendOmpWorkspaceSnapshot( + retainedWorkspaceSnapshots, + { + cwd: workspaceCwd, + checkedAt: DateTime.formatIso(now), + slashCommands: catalog.slashCommands, + skills: catalog.skills, + }, + ); + return { + ...machineSnapshot, + skills: catalog.skills, + slashCommands: catalog.slashCommands, + workspaceSnapshots: [...retainedWorkspaceSnapshots], + }; + }), + ), ); + // A user who configures a new upstream inside omp re-probes the catalog + // without restarting T3: the managed refresh re-runs the ACP discovery + // probe and publishes when the catalog moved. + const refreshModels: NonNullable = Effect.fn( + "OmpDriver.refreshModels", + )(() => snapshot.refresh.pipe(Effect.asVoid)); + return { instanceId, driverKind: DRIVER_KIND, @@ -206,6 +255,7 @@ export const OmpDriver: ProviderDriver = { snapshot, adapter, snapshotForCwd, + refreshModels, textGeneration, } satisfies ProviderInstance; }), diff --git a/apps/server/src/provider/Drivers/OmpMaintenance.test.ts b/apps/server/src/provider/Drivers/OmpMaintenance.test.ts new file mode 100644 index 000000000000..5ccb6459e1e9 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpMaintenance.test.ts @@ -0,0 +1,65 @@ +import type { ServerProviderWorkspaceSnapshot } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendOmpWorkspaceSnapshot, + OMP_MAX_WORKSPACE_SNAPSHOTS, + parseOmpUpdateCheck, +} from "./OmpMaintenance.ts"; +describe("parseOmpUpdateCheck", () => { + it("reads current and latest versions from an update-available check", () => { + expect( + parseOmpUpdateCheck("Current version: 18.1.18\nNew version available: 18.1.21\n"), + ).toEqual({ currentVersion: "18.1.18", latestVersion: "18.1.21" }); + }); + + it("leaves latest empty when omp reports itself current", () => { + expect(parseOmpUpdateCheck("Current version: 18.1.21\nAlready up to date.\n")).toEqual({ + currentVersion: "18.1.21", + latestVersion: null, + }); + }); + + it("returns nulls when the output carries no version", () => { + expect(parseOmpUpdateCheck("checking for updates...\nfailed: network unreachable\n")).toEqual({ + currentVersion: null, + latestVersion: null, + }); + }); +}); + +describe("appendOmpWorkspaceSnapshot", () => { + const entry = (cwd: string) => ({ + cwd, + checkedAt: "2026-09-14T00:00:00.000Z", + slashCommands: [], + skills: [], + }); + + it("records the first workspace", () => { + const next = appendOmpWorkspaceSnapshot([], entry("/work/a")); + expect(next.map((snapshot) => snapshot.cwd)).toEqual(["/work/a"]); + }); + + it("keeps earlier workspaces and replaces a revisited cwd instead of duplicating it", () => { + const first = appendOmpWorkspaceSnapshot([], entry("/work/a")); + const second = appendOmpWorkspaceSnapshot(first, entry("/work/b")); + expect(second.map((snapshot) => snapshot.cwd)).toEqual(["/work/a", "/work/b"]); + const third = appendOmpWorkspaceSnapshot(second, { + ...entry("/work/a"), + checkedAt: "2026-09-14T01:00:00.000Z", + }); + expect(third.map((snapshot) => snapshot.cwd)).toEqual(["/work/b", "/work/a"]); + expect(third).toHaveLength(2); + }); + + it(`evicts the least recently recorded workspace past ${OMP_MAX_WORKSPACE_SNAPSHOTS}`, () => { + let snapshots: ReadonlyArray = []; + for (let index = 0; index < OMP_MAX_WORKSPACE_SNAPSHOTS + 2; index += 1) { + snapshots = appendOmpWorkspaceSnapshot(snapshots, entry(`/work/${index}`)); + } + expect(snapshots).toHaveLength(OMP_MAX_WORKSPACE_SNAPSHOTS); + expect(snapshots[0]?.cwd).toBe("/work/2"); + expect(snapshots.at(-1)?.cwd).toBe(`/work/${OMP_MAX_WORKSPACE_SNAPSHOTS + 1}`); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpMaintenance.ts b/apps/server/src/provider/Drivers/OmpMaintenance.ts new file mode 100644 index 000000000000..08b7cb26eb79 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpMaintenance.ts @@ -0,0 +1,159 @@ +/** + * OmpMaintenance — update capabilities and workspace-snapshot helpers for the + * Oh My Pi (`omp`) driver. + * + * omp is its own updater: it ships outside npm/homebrew, so no package + * manager owns its install. `omp update --check` prints the installed + * version (`Current version: X`) and, when behind, the version it would + * install (`New version available: Y`); `omp update` performs the install. + * The maintenance resolver therefore advertises the resolved `omp` binary + * itself as the updater and bakes the `--check` output into `latestVersion`, + * instead of guessing a registry the way npm/homebrew-backed drivers do. + * + * @module provider/Drivers/OmpMaintenance + */ +import { ProviderDriverKind, type ServerProviderWorkspaceSnapshot } from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, +} from "../providerMaintenance.ts"; + +const OMP_DRIVER_KIND = ProviderDriverKind.make("omp"); + +/** Retained per-cwd catalogs, mirroring the Antigravity driver's cap. */ +export const OMP_MAX_WORKSPACE_SNAPSHOTS = 32; + +const OMP_UPDATE_CHECK_TIMEOUT_MS = 10_000; +const OMP_UPDATE_CHECK_MAX_BYTES = 64 * 1024; + +export interface OmpUpdateCheckVersions { + readonly currentVersion: string | null; + readonly latestVersion: string | null; +} + +const CURRENT_VERSION_PATTERN = /Current version:\s*v?(\d+\.\d+\.\d+)/i; +const LATEST_VERSION_PATTERNS = [ + /New version available:\s*v?(\d+\.\d+\.\d+)/i, + /Latest version:\s*v?(\d+\.\d+\.\d+)/i, + /Available version:\s*v?(\d+\.\d+\.\d+)/i, +] as const; + +/** + * Split `omp update --check` output into the installed version and the + * version an update would install. A missing "new version" line means omp + * considers itself current, so `latestVersion` stays null and callers fall + * back to `currentVersion`. + */ +export function parseOmpUpdateCheck(output: string): OmpUpdateCheckVersions { + const currentVersion = CURRENT_VERSION_PATTERN.exec(output)?.[1] ?? null; + let latestVersion: string | null = null; + for (const pattern of LATEST_VERSION_PATTERNS) { + const match = pattern.exec(output); + if (match?.[1]) { + latestVersion = match[1]; + break; + } + } + return { currentVersion, latestVersion }; +} + +/** + * Record one workspace catalog, keeping earlier workspaces so snapshot + * refreshes never drop a cwd the composer already resolved. Same shape as + * the Antigravity precedent: replace the entry for a revisited cwd, evict + * the least recently recorded workspaces past the cap. + */ +export function appendOmpWorkspaceSnapshot( + previous: ReadonlyArray, + entry: ServerProviderWorkspaceSnapshot, + maxSnapshots: number = OMP_MAX_WORKSPACE_SNAPSHOTS, +): Array { + return [...previous.filter((snapshot) => snapshot.cwd !== entry.cwd), entry].slice(-maxSnapshots); +} + +/** + * Run `omp update --check` and return its combined output, or null when omp + * fails, times out, or floods the pipe. A null never blocks the update + * button — the capability is still advertised with an unknown latest. + */ +const runOmpUpdateCheck = Effect.fn("OmpMaintenance.runUpdateCheck")(function* ( + executable: string, + env: NodeJS.ProcessEnv, +) { + const spawnCommand = yield* resolveSpawnCommand(executable, ["update", "--check"], { env }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const collect = Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env, + extendEnv: true, + shell: spawnCommand.shell, + }), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // stderr rides along: update advisories may print to either stream. + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout, maxBytes: OMP_UPDATE_CHECK_MAX_BYTES }), + collectUint8StreamText({ stream: child.stderr, maxBytes: OMP_UPDATE_CHECK_MAX_BYTES }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (Number(exitCode) !== 0 || stdout.truncated || stderr.truncated) { + return null; + } + return `${stdout.text}\n${stderr.text}`; + }); + return yield* collect.pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(OMP_UPDATE_CHECK_TIMEOUT_MS)), + Effect.map(Option.getOrNull), + Effect.catchCause((cause) => + Effect.logWarning("Oh My Pi update check failed", { + errorTag: causeErrorTag(cause), + }).pipe(Effect.as(null)), + ), + ); +}); + +/** + * omp updates itself, so the resolved executable is its own updater — the + * Cursor precedent with an `--check`-derived latest version on top. No + * resolvable binary means nothing to update, not "whatever is on PATH". + */ +export function makeOmpMaintenanceResolver(): ProviderMaintenanceCapabilitiesResolver { + return { + resolve: (context) => + Effect.gen(function* () { + if (!context) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: OMP_DRIVER_KIND, + packageName: null, + }); + } + const output = yield* runOmpUpdateCheck(context.resolvedCommandPath, context.env); + const parsed = output === null ? null : parseOmpUpdateCheck(output); + return makeProviderMaintenanceCapabilities({ + provider: OMP_DRIVER_KIND, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "omp", + platform: context.platform, + // No "new version" line means omp reports itself current; a failed + // probe leaves latest unknown rather than guessing a registry. + latestVersion: parsed ? (parsed.latestVersion ?? parsed.currentVersion) : null, + }); + }), + }; +} diff --git a/apps/server/src/provider/Drivers/OmpUsage.test.ts b/apps/server/src/provider/Drivers/OmpUsage.test.ts new file mode 100644 index 000000000000..c9b2ee7abe5a --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpUsage.test.ts @@ -0,0 +1,335 @@ +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 { + decodeOmpUsageOutput, + ompUsageToAuth, + ompUsageToLimits, + probeOmpUsage, + selectOmpBannerWindow, +} from "./OmpUsage.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const checkedAt = "2026-09-14T01:00:00.000Z"; + +const realisticPayload = JSON.stringify({ + generatedAt: "2026-09-14T00:00:00.000Z", + reports: [ + { + provider: "anthropic", + fetchedAt: "2026-09-14T00:00:00.000Z", + limits: [ + { + id: "5h", + label: "5-hour", + scope: { provider: "anthropic", windowId: "5h", shared: false }, + window: { + id: "5h", + label: "5-hour", + durationMs: 18_000_000, + resetsAt: "2026-09-14T05:00:00.000Z", + }, + amount: { + used: 42, + limit: 100, + remaining: 58, + usedFraction: 0.42, + remainingFraction: 0.58, + unit: "percent", + }, + status: "ok", + }, + { + id: "7d", + label: "7-day", + scope: { provider: "anthropic", windowId: "7d", shared: false }, + window: { + id: "7d", + label: "7-day", + durationMs: 604_800_000, + resetsAt: "2026-09-20T00:00:00.000Z", + }, + amount: { + used: 15, + limit: 100, + remaining: 85, + usedFraction: 0.15, + remainingFraction: 0.85, + unit: "percent", + }, + status: "ok", + }, + ], + }, + { + provider: "openai", + fetchedAt: "2026-09-14T00:00:00.000Z", + limits: [ + { + id: "5h", + label: "5-hour", + scope: { provider: "openai", windowId: "5h", shared: false }, + window: { + id: "5h", + label: "5-hour", + durationMs: 18_000_000, + resetsAt: "2026-09-14T05:00:00.000Z", + }, + amount: { + used: 71, + limit: 100, + remaining: 29, + usedFraction: 0.71, + remainingFraction: 0.29, + unit: "percent", + }, + status: "ok", + }, + { + id: "7d", + label: "7-day", + scope: { provider: "openai", windowId: "7d", shared: true }, + window: { + id: "7d", + label: "7-day", + durationMs: 604_800_000, + resetsAt: "2026-09-21T00:00:00.000Z", + }, + amount: { + used: 20, + limit: 100, + remaining: 80, + usedFraction: 0.2, + remainingFraction: 0.8, + unit: "percent", + }, + status: "ok", + }, + ], + }, + ], +}); + +describe("ompUsageToLimits", () => { + it("decodes two providers with 5h and 7d windows into stable per-provider rows", () => { + expect( + ompUsageToLimits({ payload: decodeOmpUsageOutput(realisticPayload), checkedAt }), + ).toEqual({ + checkedAt, + windows: [ + { + id: "anthropic:5h", + kind: "session", + label: "anthropic · 5-hour", + usedPercent: 42, + resetsAt: "2026-09-14T05:00:00.000Z", + windowDurationMins: 300, + }, + { + id: "openai:5h", + kind: "session", + label: "openai · 5-hour", + usedPercent: 71, + resetsAt: "2026-09-14T05:00:00.000Z", + windowDurationMins: 300, + }, + { + id: "anthropic:7d", + kind: "weekly", + label: "anthropic · 7-day", + usedPercent: 15, + resetsAt: "2026-09-20T00:00:00.000Z", + windowDurationMins: 10080, + }, + { + id: "openai:7d", + kind: "weekly", + label: "openai · 7-day", + usedPercent: 20, + resetsAt: "2026-09-21T00:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }); + }); + + it("yields no limits for malformed, empty, or account-less payloads", () => { + expect( + ompUsageToLimits({ payload: decodeOmpUsageOutput("not json"), checkedAt }), + ).toBeUndefined(); + expect(ompUsageToLimits({ payload: decodeOmpUsageOutput("{}"), checkedAt })).toBeUndefined(); + expect( + ompUsageToLimits({ payload: decodeOmpUsageOutput('{"reports":[]}'), checkedAt }), + ).toBeUndefined(); + expect( + ompUsageToLimits({ + payload: decodeOmpUsageOutput('{"reports":[{"provider":"x","limits":[]}]}'), + checkedAt, + }), + ).toBeUndefined(); + }); + + it("drops expired windows but keeps the report's live ones", () => { + const payload = decodeOmpUsageOutput( + JSON.stringify({ + reports: [ + { + provider: "anthropic", + limits: [ + { + id: "5h", + window: { id: "5h", durationMs: 18_000_000, resetsAt: "2026-09-13T00:00:00.000Z" }, + amount: { usedFraction: 0.99 }, + }, + { + id: "7d", + window: { id: "7d", durationMs: 604_800_000, resetsAt: "2026-09-20T00:00:00.000Z" }, + amount: { usedFraction: 0.15 }, + }, + ], + }, + ], + }), + ); + expect(ompUsageToLimits({ payload, checkedAt })?.windows.map((window) => window.id)).toEqual([ + "anthropic:7d", + ]); + }); + + it("skips one malformed limit without losing its siblings", () => { + const payload = decodeOmpUsageOutput( + JSON.stringify({ + reports: [ + { + provider: "anthropic", + limits: [ + { id: "5h", amount: { nope: true }, extra: "tolerated" }, + { + id: "7d", + window: { id: "7d", durationMs: 604_800_000, resetsAt: "2026-09-20T00:00:00.000Z" }, + amount: { usedFraction: 0.15 }, + }, + ], + }, + ], + }), + ); + expect(ompUsageToLimits({ payload, checkedAt })?.windows.map((window) => window.id)).toEqual([ + "anthropic:7d", + ]); + }); +}); + +describe("selectOmpBannerWindow", () => { + it("drives the banner from the most constrained live window", () => { + const limits = ompUsageToLimits({ payload: decodeOmpUsageOutput(realisticPayload), checkedAt }); + expect(selectOmpBannerWindow(limits?.windows ?? [])?.id).toBe("openai:5h"); + }); + + it("breaks spend ties toward the shorter window, then the smallest id", () => { + expect( + selectOmpBannerWindow([ + { id: "b:7d", kind: "weekly", label: "b", usedPercent: 50 }, + { id: "a:5h", kind: "session", label: "a", usedPercent: 50 }, + { id: "a:7d", kind: "weekly", label: "c", usedPercent: 50 }, + ])?.id, + ).toBe("a:5h"); + expect(selectOmpBannerWindow([])).toBeUndefined(); + }); +}); + +describe("ompUsageToAuth", () => { + it("reports authenticated with the provider list when accounts exist", () => { + expect(ompUsageToAuth(decodeOmpUsageOutput(realisticPayload))).toEqual({ + status: "authenticated", + type: "agent", + label: "2 providers: anthropic, openai", + }); + }); + + it("stays unknown — never unauthenticated — when the probe cannot tell", () => { + expect(ompUsageToAuth(undefined)).toEqual({ status: "unknown" }); + expect(ompUsageToAuth(decodeOmpUsageOutput("garbage"))).toEqual({ status: "unknown" }); + expect(ompUsageToAuth(decodeOmpUsageOutput('{"reports":[]}'))).toEqual({ status: "unknown" }); + }); +}); + +const node = ( + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + >, +): Effect.Effect => effect.pipe(Effect.provide(NodeServices.layer)); + +const writeUsageStub = Effect.fn("writeUsageStub")(function* (source: string) { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-usage-probe-", + }); + return writeFakeCli({ directory: dir, name: "fake-omp", source }); +}); + +describe("probeOmpUsage", () => { + effectIt.live("returns auth and limits from a realistic usage payload", () => + Effect.gen(function* () { + const binaryPath = yield* node( + writeUsageStub( + [ + 'if (process.argv[2] === "usage" && process.argv[3] === "--json") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + ` process.stdout.write(${JSON.stringify(realisticPayload)});`, + " process.exit(0);", + "}", + 'process.stderr.write("unexpected args\\n");', + "process.exit(11);", + "", + ].join("\n"), + ), + ); + const result = yield* node(probeOmpUsage({ binaryPath }, checkedAt)); + expect(result.auth).toEqual({ + status: "authenticated", + type: "agent", + label: "2 providers: anthropic, openai", + }); + expect(result.usageLimits?.windows.map((window) => window.id)).toEqual([ + "anthropic:5h", + "openai:5h", + "anthropic:7d", + "openai:7d", + ]); + }), + ); + + effectIt.live("degrades to unknown auth with no limits when the CLI fails", () => + Effect.gen(function* () { + const binaryPath = yield* node( + writeUsageStub('process.stderr.write("unknown command\\n");\nprocess.exit(1);\n'), + ); + expect(yield* node(probeOmpUsage({ binaryPath }, checkedAt))).toEqual({ + auth: { status: "unknown" }, + }); + }), + ); + + effectIt.live("degrades to unknown auth with no limits on malformed output", () => + Effect.gen(function* () { + const binaryPath = yield* node( + writeUsageStub('process.stdout.write("not json\\n");\nprocess.exit(0);\n'), + ); + expect(yield* node(probeOmpUsage({ binaryPath }, checkedAt))).toEqual({ + auth: { status: "unknown" }, + }); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/OmpUsage.ts b/apps/server/src/provider/Drivers/OmpUsage.ts new file mode 100644 index 000000000000..05e118681214 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpUsage.ts @@ -0,0 +1,411 @@ +/** + * Oh My Pi subscription usage (`omp usage --json`). + * + * The probe enumerates the authenticated accounts per upstream provider, so a + * report row is proof of credentials: any non-empty provider list means + * `authenticated`, anything else stays `unknown` (never a false + * `unauthenticated`). + * + * Window-selection rule: every non-expired window from every report is + * published (sorted session → weekly → monthly → other by `makeUsageLimits`). + * The banner-driving window is the non-expired window with the highest + * `usedPercent`; ties break toward the shorter window, then the smallest id + * (see `selectOmpBannerWindow`). A window whose `resetsAt` is at or before + * `checkedAt` already reset, so it is dropped instead of shown stale. + * Labels are prefixed with the provider only when several providers report, + * so a lone account keeps its plain `5-hour` style label. + * + * Refresh policy: probed fresh on every provider status check with a bounded + * timeout, degraded to no usage limits on any failure. Live turns refine the + * published windows through the adapter's `account.rate-limits.updated` + * events, which upsert by the stable `${provider}:${window}` ids built here — + * the sibling adapter task must reuse `ompUsageWindowId` for its updates to + * land on the probe's rows. + * + * @module provider/Drivers/OmpUsage + */ +import type { + OmpSettings, + ServerProviderAuth, + ServerProviderUsageLimits, + ServerProviderUsageWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; +import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; + +/** Bound for the read-only `omp usage --json` probe (mirrors Codex's rate-limits probe). */ +export const OMP_USAGE_PROBE_TIMEOUT_MS = 3_000; + +// Schema Structs ignore unknown keys by default, so forward-compatible CLI +// additions decode fine; every field below stays optional so one missing key +// degrades a single entry instead of failing the whole probe. +const OmpUsageAmountSchema = Schema.Struct({ + used: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Number), + remaining: Schema.optional(Schema.Number), + usedFraction: Schema.optional(Schema.Number), + remainingFraction: Schema.optional(Schema.Number), + unit: Schema.optional(Schema.String), +}); + +const OmpUsageWindowSchema = Schema.Struct({ + id: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), + durationMs: Schema.optional(Schema.Number), + resetsAt: Schema.optional(Schema.String), +}); + +const OmpUsageScopeSchema = Schema.Struct({ + provider: Schema.optional(Schema.String), + windowId: Schema.optional(Schema.String), + shared: Schema.optional(Schema.Boolean), +}); + +const OmpUsageLimitSchema = Schema.Struct({ + id: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), + scope: Schema.optional(OmpUsageScopeSchema), + window: Schema.optional(OmpUsageWindowSchema), + amount: Schema.optional(OmpUsageAmountSchema), + status: Schema.optional(Schema.String), +}); + +const OmpUsageReportSchema = Schema.Struct({ + provider: Schema.optional(Schema.String), + fetchedAt: Schema.optional(Schema.String), + // Decoded entry-by-entry below so one malformed limit cannot sink the rest. + limits: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +const OmpUsagePayloadSchema = Schema.Struct({ + generatedAt: Schema.optional(Schema.String), + // Same per-entry tolerance as limits: a bad report is skipped, not fatal. + reports: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +export type OmpUsagePayload = typeof OmpUsagePayloadSchema.Type; +type OmpUsageReport = typeof OmpUsageReportSchema.Type; +type OmpUsageLimit = typeof OmpUsageLimitSchema.Type; + +/** Best-effort top-level decode: `undefined` means the probe cannot tell anything. */ +export function decodeOmpUsageOutput(stdout: string): OmpUsagePayload | undefined { + const trimmed = stdout.trim(); + if (!trimmed.startsWith("{")) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return undefined; + } + const decoded = Schema.decodeUnknownOption(OmpUsagePayloadSchema)(parsed); + return Option.getOrUndefined(decoded); +} + +function decodeReports(payload: OmpUsagePayload): ReadonlyArray { + const reports: Array = []; + for (const raw of payload.reports ?? []) { + const decoded = Schema.decodeUnknownOption(OmpUsageReportSchema)(raw); + if (Option.isSome(decoded)) { + reports.push(decoded.value); + } + } + return reports; +} + +function decodeLimits(report: OmpUsageReport): ReadonlyArray { + const limits: Array = []; + for (const raw of report.limits ?? []) { + const decoded = Schema.decodeUnknownOption(OmpUsageLimitSchema)(raw); + if (Option.isSome(decoded)) { + limits.push(decoded.value); + } + } + return limits; +} + +/** Distinct authenticated provider names in first-seen order. */ +export function ompUsageProviders(payload: OmpUsagePayload): ReadonlyArray { + const providers: Array = []; + for (const report of decodeReports(payload)) { + const provider = report.provider?.trim(); + if (provider && !providers.includes(provider)) { + providers.push(provider); + } + } + return providers; +} + +/** + * Auth derived from the probe: a report row enumerates an authenticated + * account, so any provider present proves credentials. Anything else is + * `unknown` — the probe cannot distinguish logged-out from broken. + */ +export function ompUsageToAuth(payload: OmpUsagePayload | undefined): ServerProviderAuth { + const providers = payload ? ompUsageProviders(payload) : []; + if (providers.length === 0) { + return { status: "unknown" }; + } + return { + status: "authenticated", + // The single ACP `agent` method backed by local credentials. + type: "agent", + label: + providers.length === 1 + ? providers[0]! + : `${providers.length} providers: ${providers.join(", ")}`, + }; +} + +const MONTH_MINS = 30 * 24 * 60; +const WEEK_MINS = 7 * 24 * 60; + +function kindForDurationMins(mins: number): ServerProviderUsageWindow["kind"] { + if (mins >= MONTH_MINS) { + return "monthly"; + } + if (mins >= WEEK_MINS) { + return "weekly"; + } + return "session"; +} + +// omp window ids read like `5h`/`7d`; fall back to token sniffing only when +// the CLI omits `durationMs`, so a rename cannot silently mislabel a window. +function kindForWindowId(windowId: string): ServerProviderUsageWindow["kind"] | undefined { + const normalized = windowId.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (/(month|30d)/.test(normalized)) { + return "monthly"; + } + if (/(week|7d|[^0-9]d\b|\dd\b)/.test(normalized)) { + return "weekly"; + } + if (/(hour|session|[^0-9]h\b|\dh\b|min)/.test(normalized)) { + return "session"; + } + return undefined; +} + +/** + * Stable probe id. Upstream providers repeat the same window ids (`5h`, + * `7d`), so the provider prefix keeps their rows distinct and lets runtime + * `account.rate-limits.updated` events upsert by id onto the probe's rows. + */ +export function ompUsageWindowId(provider: string, windowId: string): string { + return `${provider}:${windowId}`; +} + +// `usedFraction` reads 0–1, but a future CLI may already emit percent; values +// above 1 pass through and the clamp below keeps both scales inside 0–100. +function normalizeUsedPercent(amount: OmpUsageLimit["amount"]): number | undefined { + if (typeof amount?.usedFraction === "number" && Number.isFinite(amount.usedFraction)) { + return clampPercent(amount.usedFraction > 1 ? amount.usedFraction : amount.usedFraction * 100); + } + if (typeof amount?.remainingFraction === "number" && Number.isFinite(amount.remainingFraction)) { + const remaining = + amount.remainingFraction > 1 ? amount.remainingFraction : amount.remainingFraction * 100; + return clampPercent(100 - remaining); + } + if ( + typeof amount?.used === "number" && + typeof amount?.limit === "number" && + Number.isFinite(amount.used) && + Number.isFinite(amount.limit) && + amount.limit > 0 + ) { + return clampPercent((amount.used / amount.limit) * 100); + } + return undefined; +} + +function parseIsoDate(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +// Effect represents dates through `DateTime`; this mirrors the Codex usage +// mapping, which builds its reset timestamps the same way. +function isoFromMillis(value: number): string | undefined { + const dateTime = DateTime.make(value); + return Option.isSome(dateTime) ? DateTime.formatIso(dateTime.value) : undefined; +} + +function ompUsageLimitToWindow(input: { + readonly provider: string; + readonly limit: OmpUsageLimit; + readonly qualifyLabel: boolean; + readonly checkedAtMs: number | undefined; +}): ServerProviderUsageWindow | undefined { + const { provider, limit, qualifyLabel, checkedAtMs } = input; + const windowId = + limit.window?.id?.trim() || limit.scope?.windowId?.trim() || limit.id?.trim() || undefined; + if (!windowId) { + return undefined; + } + const usedPercent = normalizeUsedPercent(limit.amount); + if (usedPercent === undefined) { + return undefined; + } + const resetsAtMs = parseIsoDate(limit.window?.resetsAt?.trim()); + if (resetsAtMs !== undefined && checkedAtMs !== undefined && resetsAtMs <= checkedAtMs) { + return undefined; + } + const durationMins = + typeof limit.window?.durationMs === "number" && + Number.isFinite(limit.window.durationMs) && + limit.window.durationMs > 0 + ? Math.round(limit.window.durationMs / 60_000) + : undefined; + const kind = + durationMins !== undefined + ? kindForDurationMins(durationMins) + : (kindForWindowId(windowId) ?? "other"); + const baseLabel = limit.label?.trim() || limit.window?.label?.trim() || windowId; + const resetsAt = resetsAtMs !== undefined ? isoFromMillis(resetsAtMs) : undefined; + return { + id: ompUsageWindowId(provider, windowId), + kind, + label: qualifyLabel ? `${provider} · ${baseLabel}` : baseLabel, + usedPercent, + ...(resetsAt !== undefined ? { resetsAt } : {}), + ...(durationMins !== undefined ? { windowDurationMins: durationMins } : {}), + }; +} + +/** Every publishable window across all reports; `undefined` when none survive. */ +export function ompUsageToLimits(input: { + readonly payload: OmpUsagePayload | undefined; + readonly checkedAt: string; +}): ServerProviderUsageLimits | undefined { + if (!input.payload) { + return undefined; + } + const reports = decodeReports(input.payload); + const providers = ompUsageProviders(input.payload); + const qualifyLabel = providers.length > 1; + const checkedAtMs = parseIsoDate(input.checkedAt); + const windows: Array = []; + for (const report of reports) { + const provider = report.provider?.trim() || "omp"; + for (const limit of decodeLimits(report)) { + const window = ompUsageLimitToWindow({ + provider, + limit, + qualifyLabel, + checkedAtMs, + }); + if (window) { + windows.push(window); + } + } + } + if (windows.length === 0) { + return undefined; + } + return makeUsageLimits({ checkedAt: input.checkedAt, windows }); +} + +const BANNER_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * The window the composer banner is driven by: highest spend first, then the + * shorter window, then the smallest id. Purely a selection rule — publishing + * keeps every window via `makeUsageLimits` ordering. + */ +export function selectOmpBannerWindow( + windows: Iterable, +): ServerProviderUsageWindow | undefined { + let selected: ServerProviderUsageWindow | undefined; + for (const window of windows) { + if ( + !selected || + window.usedPercent > selected.usedPercent || + (window.usedPercent === selected.usedPercent && + (BANNER_KIND_ORDER[window.kind] - BANNER_KIND_ORDER[selected.kind] || + window.id.localeCompare(selected.id)) < 0) + ) { + selected = window; + } + } + return selected; +} + +export interface OmpUsageProbeResult { + readonly auth: ServerProviderAuth; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const degradedOmpUsage: OmpUsageProbeResult = { auth: { status: "unknown" } }; + +function degradedOmpUsageResult(reason: string): Effect.Effect { + return Effect.as(Effect.logDebug(`Oh My Pi usage probe degraded: ${reason}.`), degradedOmpUsage); +} + +/** + * Read-only `omp usage --json` probe. Never fails and never blocks startup: + * every failure mode (missing CLI, timeout, non-zero exit, malformed JSON, + * empty reports) degrades to unknown auth with no usage limits, leaving the + * version/ACP health check to report CLI problems. + */ +export const probeOmpUsage = ( + ompSettings: Pick, + checkedAt: string, + environment?: NodeJS.ProcessEnv, +): Effect.Effect => { + const probe = Effect.gen(function* () { + const command = ompSettings.binaryPath || "omp"; + const spawnCommand = yield* resolveSpawnCommand( + command, + ["usage", "--json"], + environment ? { env: environment } : {}, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, + }), + ); + }); + return probe.pipe( + Effect.timeoutOption(OMP_USAGE_PROBE_TIMEOUT_MS), + Effect.flatMap((result) => { + if (Option.isNone(result)) { + return degradedOmpUsageResult("timed out"); + } + const output = result.value; + if (output.code !== 0) { + return degradedOmpUsageResult(`exited with status ${output.code}`); + } + const payload = decodeOmpUsageOutput(output.stdout); + if (!payload) { + return degradedOmpUsageResult("unparseable output"); + } + const usageLimits = ompUsageToLimits({ payload, checkedAt }); + return Effect.succeed({ + auth: ompUsageToAuth(payload), + ...(usageLimits ? { usageLimits } : {}), + } satisfies OmpUsageProbeResult); + }), + Effect.catch(() => degradedOmpUsageResult("spawn failed")), + ); +}; diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index e3d3da36bb45..9e79b7acfafe 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -2508,4 +2508,207 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { // hang until the suite timeout instead of failing here. }).pipe(TestClock.withLive), ); + + it.effect("feeds the context meter from omp's usage_update", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-usage-update-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_USAGE_UPDATE: "1" }), + ); + 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", + }); + const turn = yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.isDefined(usageEvent); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepStrictEqual(usageEvent.payload.usage, { + usedTokens: 39451, + maxTokens: 1_000_000, + }); + assert.equal(String(usageEvent.turnId), String(turn.turnId)); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reports the finished turn's token split from the prompt response", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-prompt-usage-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_PROMPT_RESPONSE_USAGE: "1" }), + ); + 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", + }); + yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.isDefined(usageEvent); + if (usageEvent?.type === "thread.token-usage.updated") { + // No usage_update was sent, so the turn total stands in for context + // occupancy and the snapshot carries no window. + assert.deepStrictEqual(usageEvent.payload.usage, { + usedTokens: 1_801, + inputTokens: 1_234, + cachedInputTokens: 890, + outputTokens: 567, + lastUsedTokens: 1_801, + lastInputTokens: 1_234, + lastCachedInputTokens: 890, + lastOutputTokens: 567, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("advertises /compact compaction and leaves the session usable after it", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-compaction-probe"); + assert.deepStrictEqual(adapter.compaction, { + type: "slash-command", + command: "/compact", + }); + + 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 runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.filter((event) => event.type === "turn.started" || event.type === "turn.completed"), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + // ProviderService dispatches a slash-command compaction as a turn. + const compactionTurn = yield* adapter.sendTurn({ + threadId, + input: adapter.compaction?.type === "slash-command" ? adapter.compaction.command : "", + attachments: [], + }); + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "after compaction", + attachments: [], + }); + // A leaked in-flight count would make this a steer of the compaction + // turn instead of a new turn. + assert.notEqual(String(nextTurn.turnId), String(compactionTurn.turnId)); + + const turnEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepStrictEqual( + turnEvents.map((event) => event.type), + ["turn.started", "turn.completed", "turn.started", "turn.completed"], + ); + + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptTexts = requests + .filter((entry) => entry.method === "session/prompt") + .map( + (entry) => + (entry.params as { prompt?: ReadonlyArray<{ text?: string }> } | undefined)?.prompt?.[0] + ?.text, + ); + assert.deepStrictEqual(promptTexts, ["/compact", "after compaction"]); + }), + ); + + // omp maps its todo_auto_clear onto a `plan` update with zero entries, and + // the web timeline reads an empty plan as "clear the panel". The shared ACP + // parser drops empty plans, so without the adapter's own handling the panel + // kept showing a finished plan for the rest of the thread. + it.effect("forwards omp's cleared plan so the todo panel empties", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-plan-clear-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_EMPTY_PLAN_AFTER_PLAN: "1" }), + ); + 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", + }); + yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const planUpdates = runtimeEvents.filter((event) => event.type === "turn.plan.updated"); + assert.equal(planUpdates.length, 2); + assert.deepStrictEqual( + planUpdates.map((event) => + event.type === "turn.plan.updated" ? event.payload.plan.length : -1, + ), + [2, 0], + ); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index aabb49dcbd85..0fdcc24ebc5e 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -21,6 +21,7 @@ import { RuntimeTaskId, type RuntimeMode, type ThreadId, + type ThreadTokenUsageSnapshot, TurnId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -67,6 +68,7 @@ import { type AcpSessionModeState, type AcpToolCallState, parsePermissionRequest, + sessionUpdateIsReplay, } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { @@ -148,6 +150,11 @@ interface OmpSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + /** Context occupancy last reported by omp's `usage_update`. The + * `session/prompt` response only carries per-turn tokens, so the context + * meter keeps reading these until omp reports a new occupancy. */ + lastContextUsedTokens: number | undefined; + lastContextWindowTokens: number | 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. */ @@ -576,6 +583,63 @@ function summarizeOmpTaskResult(rawOutput: unknown): string | undefined { return text ? truncateTaskText(text, OMP_TASK_RESULT_MAX_CHARS) : undefined; } +function nonNegativeTokenCount(value: number | null | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.trunc(value) + : undefined; +} + +/** + * Projects omp's two token-usage sources onto one context-meter snapshot. + * + * `usage_update` carries the session's context occupancy (`used` of `size`); + * the `session/prompt` response carries the finished turn's token split. The + * meter reads `usedTokens`/`maxTokens`, so a prompt response alone (no + * context window observed yet) falls back to the turn's total and the + * `last*` fields stay the per-turn numbers, matching Claude and Codex. + * + * omp also reports a cumulative `cost` on `usage_update`; the runtime token + * snapshot has no currency field, so it is deliberately dropped here. + */ +function makeOmpTokenUsageSnapshot(input: { + readonly contextUsedTokens?: number | null | undefined; + readonly contextWindowTokens?: number | null | undefined; + readonly turnUsage?: EffectAcpSchema.Usage | null | undefined; +}): ThreadTokenUsageSnapshot | undefined { + const contextWindowTokens = nonNegativeTokenCount(input.contextWindowTokens); + const maxTokens = + contextWindowTokens !== undefined && contextWindowTokens > 0 ? contextWindowTokens : undefined; + const turnTotalTokens = nonNegativeTokenCount(input.turnUsage?.totalTokens); + const activeTokens = nonNegativeTokenCount(input.contextUsedTokens) ?? turnTotalTokens; + if (activeTokens === undefined || activeTokens <= 0) { + return undefined; + } + const usedTokens = maxTokens === undefined ? activeTokens : Math.min(activeTokens, maxTokens); + const inputTokens = nonNegativeTokenCount(input.turnUsage?.inputTokens); + const outputTokens = nonNegativeTokenCount(input.turnUsage?.outputTokens); + const cachedInputTokens = nonNegativeTokenCount(input.turnUsage?.cachedReadTokens); + const reasoningOutputTokens = nonNegativeTokenCount(input.turnUsage?.thoughtTokens); + + return { + usedTokens, + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(turnTotalTokens !== undefined && turnTotalTokens > usedTokens + ? { totalProcessedTokens: turnTotalTokens } + : {}), + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(turnTotalTokens !== undefined ? { lastUsedTokens: turnTotalTokens } : {}), + ...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}), + ...(reasoningOutputTokens !== undefined + ? { lastReasoningOutputTokens: reasoningOutputTokens } + : {}), + }; +} + export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiveOptions) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("omp"); @@ -690,6 +754,33 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ); }); + /** + * Publishes the context meter's feed. omp reports context occupancy on + * `usage_update` and the per-turn split on the `session/prompt` + * response, so both callers fold their numbers into the same snapshot + * shape the other adapters emit. + */ + const emitTokenUsage = ( + ctx: OmpSessionContext, + usage: ThreadTokenUsageSnapshot, + raw: { readonly method: string; readonly payload: unknown }, + ) => + Effect.gen(function* () { + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { usage }, + raw: { + source: "acp.jsonrpc", + method: raw.method, + payload: raw.payload, + }, + }); + }); + /** * Projects omp's `task` tool calls into Agents-panel lifecycle events. * The plain tool_call runtime event is still emitted alongside (Claude @@ -823,6 +914,10 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), ); let ctx!: OmpSessionContext; + // Bound after `acp.start()`; the side-channel session/update + // handler below is registered before the session exists and must + // ignore notifications until the root session id is known. + let rootSessionId: string | undefined; const resumeSessionId = parseOmpResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ @@ -1034,12 +1129,69 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv } return runElicitationFlow("elicitation/create", params, params); }); + // Side channel for the two session/update kinds the shared ACP + // parser drops: `usage_update` (context meter) and a `plan` with + // zero entries (omp's todo_auto_clear, which the todo panel + // reads as "clear the plan"). Handlers are additive, so the + // runtime's own parser still owns every other update kind; this + // one drains the runtime's event queue first so a clear can + // never overtake the plan it clears. + yield* acp.handleSessionUpdate((notification) => + Effect.gen(function* () { + const sessionCtx = sessions.get(input.threadId); + if ( + sessionCtx === undefined || + sessionCtx.stopped || + sessionCtx.acp !== acp || + rootSessionId === undefined || + notification.sessionId !== rootSessionId || + sessionUpdateIsReplay(notification) + ) { + return; + } + const update = notification.update; + if (update.sessionUpdate === "usage_update") { + yield* logNative(sessionCtx.threadId, "session/update", notification); + sessionCtx.lastContextUsedTokens = + nonNegativeTokenCount(update.used) ?? sessionCtx.lastContextUsedTokens; + sessionCtx.lastContextWindowTokens = + nonNegativeTokenCount(update.size) ?? sessionCtx.lastContextWindowTokens; + const usage = makeOmpTokenUsageSnapshot({ + contextUsedTokens: sessionCtx.lastContextUsedTokens, + contextWindowTokens: sessionCtx.lastContextWindowTokens, + }); + if (!usage) { + return; + } + yield* acp.drainEvents; + yield* emitTokenUsage(sessionCtx, usage, { + method: "session/update", + payload: notification, + }); + return; + } + if (update.sessionUpdate === "plan" && update.entries.length === 0) { + yield* logNative(sessionCtx.threadId, "session/update", notification); + yield* acp.drainEvents; + yield* emitPlanUpdate(sessionCtx, { plan: [] }, notification); + } + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Oh My Pi ACP session update.", + cause, + }), + ), + ), + ); return yield* acp.start(); }).pipe( Effect.mapError((error) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), ), ); + rootSessionId = started.sessionId; const startConfiguration = yield* applyRequestedSessionConfiguration({ runtime: acp, @@ -1081,6 +1233,8 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + lastContextUsedTokens: undefined, + lastContextWindowTokens: undefined, promptsInFlight: 0, dispatchLock, stopped: false, @@ -1444,6 +1598,22 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ...(effectiveModel ? { model: effectiveModel } : {}), }; + // The prompt response's per-turn split lands before the turn + // settles so the meter never shows a finished turn with stale + // numbers. Context occupancy still comes from the last + // `usage_update`; omp reports no window on this response. + const promptUsage = makeOmpTokenUsageSnapshot({ + contextUsedTokens: ctx.lastContextUsedTokens, + contextWindowTokens: ctx.lastContextWindowTokens, + turnUsage: result.usage, + }); + if (promptUsage && !ctx.stopped) { + yield* emitTokenUsage(ctx, promptUsage, { + method: "session/prompt", + payload: result, + }); + } + // 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. @@ -1647,6 +1817,11 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv // capability as unsupported instead of reporting a rollback the live // session does not reflect. capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + // omp exposes compaction as its own `/compact` command, not as an ACP + // method, so ProviderService dispatches it as an ordinary turn (same + // shape as Cursor's `/compress`) and settles on that turn's + // turn.completed. + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts index a109d7d5502d..75a9303b198f 100644 --- a/apps/server/src/provider/Layers/OmpProvider.test.ts +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -90,6 +90,130 @@ const makeMockAgentWithVersionWrapper = Effect.fn("makeMockAgentWithVersionWrapp }); }); +const ompUsageFixturePayload = JSON.stringify({ + generatedAt: "2030-01-01T00:00:00.000Z", + reports: [ + { + provider: "anthropic", + fetchedAt: "2030-01-01T00:00:00.000Z", + limits: [ + { + id: "5h", + label: "5-hour", + scope: { provider: "anthropic", windowId: "5h", shared: false }, + window: { + id: "5h", + label: "5-hour", + durationMs: 18_000_000, + resetsAt: "2030-01-01T05:00:00.000Z", + }, + amount: { + used: 42, + limit: 100, + remaining: 58, + usedFraction: 0.42, + remainingFraction: 0.58, + unit: "percent", + }, + status: "ok", + }, + { + id: "7d", + label: "7-day", + scope: { provider: "anthropic", windowId: "7d", shared: false }, + window: { + id: "7d", + label: "7-day", + durationMs: 604_800_000, + resetsAt: "2030-01-08T00:00:00.000Z", + }, + amount: { + used: 15, + limit: 100, + remaining: 85, + usedFraction: 0.15, + remainingFraction: 0.85, + unit: "percent", + }, + status: "ok", + }, + ], + }, + { + provider: "openai", + fetchedAt: "2030-01-01T00:00:00.000Z", + limits: [ + { + id: "5h", + label: "5-hour", + scope: { provider: "openai", windowId: "5h", shared: false }, + window: { + id: "5h", + label: "5-hour", + durationMs: 18_000_000, + resetsAt: "2030-01-01T05:00:00.000Z", + }, + amount: { + used: 71, + limit: 100, + remaining: 29, + usedFraction: 0.71, + remainingFraction: 0.29, + unit: "percent", + }, + status: "ok", + }, + { + id: "7d", + label: "7-day", + scope: { provider: "openai", windowId: "7d", shared: false }, + window: { + id: "7d", + label: "7-day", + durationMs: 604_800_000, + resetsAt: "2030-01-08T00:00:00.000Z", + }, + amount: { + used: 20, + limit: 100, + remaining: 80, + usedFraction: 0.2, + remainingFraction: 0.8, + unit: "percent", + }, + status: "ok", + }, + ], + }, + ], +}); + +const makeMockAgentWithUsageWrapper = Effect.fn("makeMockAgentWithUsageWrapper")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-usage-mock-", + }); + return writeFakeCli({ + directory: dir, + name: "fake-omp", + env: { T3_ACP_OMP_SHAPES: "1" }, + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("omp/18.0.6\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "usage" && process.argv[3] === "--json") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + ` process.stdout.write(${JSON.stringify(ompUsageFixturePayload)});`, + " process.exit(0);", + "}", + execScriptSource({ scriptPath: mockAgentPath }), + ].join("\n"), + }); +}); + const waitForFileContent = Effect.fn("waitForFileContent")(function* ( filePath: string, attempts = 40, @@ -240,6 +364,38 @@ describe("buildOmpProviderSnapshot", () => { ], }); }); + + it("publishes the context-window flag with auth and usage limits", () => { + expect( + buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: baseOmpSettings, + version: "18.0.6", + auth: { status: "authenticated", type: "agent", label: "anthropic" }, + usageLimits: { + checkedAt: "2026-01-01T00:00:00.000Z", + windows: [{ id: "anthropic:5h", kind: "session", label: "5-hour", usedPercent: 42 }], + }, + }), + ).toMatchObject({ + reportsContextWindow: true, + auth: { status: "authenticated", label: "anthropic" }, + usageLimits: { + windows: [{ id: "anthropic:5h", usedPercent: 42 }], + }, + }); + }); + + it("defaults to unknown auth with no usage limits when the probe degraded", () => { + const snapshot = buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: baseOmpSettings, + version: "18.0.6", + }); + expect(snapshot.reportsContextWindow).toBe(true); + expect(snapshot.auth).toEqual({ status: "unknown" }); + expect(snapshot.usageLimits).toBeUndefined(); + }); }); describe("buildOmpCapabilitiesFromConfigOptions", () => { @@ -356,6 +512,40 @@ describe("checkOmpProviderStatus", () => { expect(requestLog).toContain("initialize"); }), ); + effectIt.live("reports authenticated usage limits from omp usage --json", () => + Effect.gen(function* () { + const wrapperPath = yield* node(makeMockAgentWithUsageWrapper()); + + const provider = yield* node( + checkOmpProviderStatus({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }), + ); + + expect(provider).toMatchObject({ + installed: true, + version: "18.0.6", + status: "ready", + reportsContextWindow: true, + auth: { + status: "authenticated", + type: "agent", + label: "2 providers: anthropic, openai", + }, + }); + expect(provider.usageLimits?.windows.map((window) => window.id)).toEqual([ + "anthropic:5h", + "openai:5h", + "anthropic:7d", + "openai:7d", + ]); + expect(provider.usageLimits?.windows.map((window) => window.usedPercent)).toEqual([ + 42, 71, 15, 20, + ]); + }), + ); }); describe("discoverOmpModelsViaAcp", () => { diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts index 2d8f03074b56..2361d0dd243a 100644 --- a/apps/server/src/provider/Layers/OmpProvider.ts +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -3,8 +3,10 @@ import type { ModelCapabilities, ProviderOptionSelection, ServerProvider, + ServerProviderAuth, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -39,11 +41,14 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; - +import { probeOmpUsage } from "../Drivers/OmpUsage.ts"; const OMP_PRESENTATION = { displayName: "Oh My Pi", badgeLabel: "Early Access", showInteractionModeToggle: true, + // The adapter streams ACP `usage_update` token ticks, so a started thread + // has a live context meter once its activities load. + reportsContextWindow: true, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -578,6 +583,8 @@ export function buildOmpProviderSnapshot(input: { readonly message?: string; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + readonly auth?: ServerProviderAuth; + readonly usageLimits?: ServerProviderUsageLimits; }): ServerProviderDraft { const status = input.status ?? "ready"; const message = joinProviderMessages(input.message, input.discoveryWarning); @@ -594,8 +601,9 @@ export function buildOmpProviderSnapshot(input: { installed: true, version: input.version, status: input.discoveryWarning && status === "ready" ? "warning" : status, - auth: { status: "unknown" }, + auth: input.auth ?? { status: "unknown" }, ...(message ? { message } : {}), + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }, }); } @@ -712,10 +720,19 @@ export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(functi 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), - ), + // The usage probe is read-only and bounded by its own timeout, so it rides + // alongside ACP discovery instead of stretching the health check. It never + // fails: every failure mode degrades to unknown auth with no limits. + const [discoveryExit, usageProbe] = yield* Effect.all( + [ + Effect.exit( + discoverOmpModelsViaAcp(ompSettings, environment).pipe( + Effect.timeoutOption(OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), + ), + probeOmpUsage(ompSettings, checkedAt, environment), + ], + { concurrency: 2 }, ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Oh My Pi ACP model discovery failed", { @@ -747,6 +764,8 @@ export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(functi } : {}), ...(discoveryWarning ? { discoveryWarning } : {}), + auth: usageProbe.auth, + ...(usageProbe.usageLimits ? { usageLimits: usageProbe.usageLimits } : {}), }); }); From 5b75de153720e8a6ebfd17dde982fc47c5e52281 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 11:58:13 -0300 Subject: [PATCH 07/22] feat(omp): session fork/list, live command refresh, subagent progress, model-swap warning --- apps/server/scripts/acp-mock-agent.ts | 213 +++++++- .../src/provider/Drivers/OmpCommands.test.ts | 37 +- .../src/provider/Drivers/OmpCommands.ts | 84 +-- .../src/provider/Drivers/OmpDriver.test.ts | 141 ++++- apps/server/src/provider/Drivers/OmpDriver.ts | 167 ++++-- .../src/provider/Layers/OmpAdapter.test.ts | 383 ++++++++++++++ apps/server/src/provider/Layers/OmpAdapter.ts | 482 +++++++++++++++++- .../src/provider/Layers/OmpProvider.test.ts | 266 ++++++++++ .../server/src/provider/Layers/OmpProvider.ts | 106 +++- .../src/provider/Services/OmpAdapter.ts | 73 ++- .../src/provider/acp/OmpAcpSupport.test.ts | 80 +++ apps/server/src/provider/acp/OmpAcpSupport.ts | 89 ++++ .../components/ComposerPromptEditor.test.ts | 18 + .../src/components/ComposerPromptEditor.tsx | 15 +- docs/internals/providers.md | 38 ++ 15 files changed, 2064 insertions(+), 128 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 78ca4b95a3a7..201f1ae8c6ce 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -58,6 +58,18 @@ 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"; +// omp streams subagent state inside the task tool's own payload +// (`details.progress` while agents run, `details.results` once they +// settle). Off by default so the suites sharing this agent keep their +// event counts. +const emitTaskToolProgress = process.env.T3_ACP_EMIT_TASK_TOOL_PROGRESS === "1"; +// omp republishes its command catalog per session while a turn runs. +const emitSessionCommands = process.env.T3_ACP_EMIT_SESSION_COMMANDS === "1"; +// omp advertises session list/fork/close and serves both methods. +const ompSessionStore = process.env.T3_ACP_OMP_SESSION_STORE === "1"; +// One malformed entry mixed into session/list is impossible to send +// through the typed agent response, so list shapes stay well-formed here. +const sessionListCwd = process.env.T3_ACP_SESSION_LIST_CWD; // omp reports context occupancy through `usage_update` and the finished // turn's token split through the `session/prompt` response; both are // off by default so the suites sharing this agent keep their event counts. @@ -459,6 +471,48 @@ const program = Effect.gen(function* () { }, }); + if (ompSessionStore) { + // Mirrors omp: the store is global, so a cwd filter narrows the same + // list a caller would otherwise get in full, and transcript stats ride + // along under `_meta`. + const storedSessions = [ + { + sessionId: "omp-session-terminal-1", + cwd: sessionListCwd ?? process.cwd(), + title: "Terminal session", + updatedAt: "2026-02-03T04:05:06.000Z", + _meta: { messageCount: 12, size: 8192 }, + }, + { + sessionId: "omp-session-elsewhere-1", + cwd: "/somewhere/else", + updatedAt: "2026-02-02T01:02:03.000Z", + _meta: { messageCount: 3, size: 512 }, + }, + ]; + yield* agent.handleListSessions((request) => + Effect.succeed({ + sessions: request.cwd + ? storedSessions.filter((entry) => entry.cwd === request.cwd) + : storedSessions, + nextCursor: "mock-cursor-2", + }), + ); + yield* agent.handleForkSession((request) => + request.cwd + ? Effect.succeed({ + sessionId: `${request.sessionId}-fork-1`, + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }) + : // omp fails a cwd-less fork inside its own path handling. + AcpError.AcpRequestError.internalError( + 'The "path" property must be of type string, got undefined', + ), + ); + } + yield* agent.handleInitialize((request) => Effect.gen(function* () { if (floodStderr) { @@ -486,7 +540,12 @@ const program = Effect.gen(function* () { } return { protocolVersion: 1, - agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } }, + agentCapabilities: { + loadSession: true, + sessionCapabilities: ompSessionStore + ? { list: {}, fork: {}, resume: {}, close: {} } + : { resume: {} }, + }, // Grok advertises model state before any session exists; the provider // health check reads it from here without authenticating. _meta: { modelState: modelState() }, @@ -740,6 +799,25 @@ const program = Effect.gen(function* () { yield* Effect.sleep(`${promptDelayMs} millis`); } + // omp republishes the whole catalog — native commands and the + // `skill:`-prefixed ones alike — when a session's command set changes. + if (emitSessionCommands) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "compact", description: "Compact the context" }, + { + name: "skill:tdd", + description: "Test-driven development", + input: { hint: "task" }, + }, + ], + }, + }); + } + if (failPrompt) { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } @@ -1135,6 +1213,139 @@ const program = Effect.gen(function* () { }, }); + // omp's real task payload: `{ content, details }`, with a + // `details.progress` snapshot per in-flight agent and + // `details.results` once they settle. + if (emitTaskToolProgress) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + rawOutput: { + content: [{ type: "text", text: "Running agent Worker..." }], + details: { + projectAgentsDir: null, + results: [], + totalDurationMs: 1200, + progress: [ + { + index: 0, + id: "Worker", + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Implement the feature", + assignment: "Implement the feature", + description: "Implement the feature", + lastIntent: "Reading the adapter", + currentTool: "read", + recentTools: [{ tool: "glob", args: "src/**/*.ts", endMs: 1749200040000 }], + recentOutput: [], + toolCount: 4, + requests: 2, + tokens: 1200, + cost: 0.01, + durationMs: 1200, + contextTokens: 900, + contextWindow: 200000, + resolvedModel: "anthropic/claude-sonnet", + }, + ], + }, + }, + }, + }); + // Same agent snapshot, more streamed tool output: the tool row + // grows (so the ACP runtime forwards the update) while the + // subagent state omp reports is unchanged. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "y".repeat(400) } }], + rawOutput: { + content: [{ type: "text", text: "Running agent Worker..." }], + details: { + projectAgentsDir: null, + results: [], + totalDurationMs: 1400, + progress: [ + { + index: 0, + id: "Worker", + agent: "worker", + agentSource: "bundled", + status: "running", + task: "Implement the feature", + assignment: "Implement the feature", + description: "Implement the feature", + lastIntent: "Reading the adapter", + currentTool: "read", + recentTools: [{ tool: "glob", args: "src/**/*.ts", endMs: 1749200040000 }], + recentOutput: [], + toolCount: 4, + requests: 2, + tokens: 1200, + cost: 0.01, + durationMs: 1400, + contextTokens: 900, + contextWindow: 200000, + resolvedModel: "anthropic/claude-sonnet", + }, + ], + }, + }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + content: [{ type: "text", text: "Agent Worker completed." }], + details: { + projectAgentsDir: null, + totalDurationMs: 4800, + results: [ + { + index: 0, + id: "Worker", + agent: "worker", + agentSource: "bundled", + description: "Implement the feature", + task: "Implement the feature", + assignment: "Implement the feature", + exitCode: 0, + output: "subagent finished the work", + truncated: false, + durationMs: 4800, + tokens: 3400, + requests: 5, + contextTokens: 2100, + contextWindow: 200000, + resolvedModel: "anthropic/claude-sonnet", + }, + ], + }, + }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "task tool done" }, + }, + }); + return { stopReason: "end_turn" }; + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts index 3795ef6887a0..3a995ab5d610 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.test.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { decodeOmpCommandCatalog } from "./OmpCommands.ts"; +import { catalogFromCommandEntries, decodeOmpCommandCatalog } from "./OmpCommands.ts"; const frame = (commands: ReadonlyArray) => // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. @@ -129,3 +129,38 @@ describe("decodeOmpCommandCatalog", () => { }); }); }); + +describe("catalogFromCommandEntries", () => { + it("splits raw live entries with the same skill: rule as the probe", () => { + const catalog = catalogFromCommandEntries([ + { name: "skill:tdd", description: "Test-driven development." }, + { name: "skillful", description: "Toggle skill listing." }, + { name: "security", description: "Run security scans", input: { hint: "" } }, + { name: "skill:" }, + { name: " " }, + "not-an-object", + ]); + + expect(catalog.skills).toEqual([ + { + name: "tdd", + path: "skill://tdd/SKILL.md", + enabled: true, + description: "Test-driven development.", + }, + ]); + expect(catalog.slashCommands).toEqual([ + { name: "security", description: "Run security scans", input: { hint: "" } }, + { name: "skillful", description: "Toggle skill listing." }, + ]); + }); + + it("matches decodeOmpCommandCatalog for the same entries", () => { + const entries = [ + { name: "skill:deploy", description: "Deploy the app" }, + { name: "share", description: "Share the session" }, + { name: "skill:deploy", description: "Deploy the app (updated)" }, + ]; + expect(catalogFromCommandEntries(entries)).toEqual(decodeOmpCommandCatalog(frame(entries))); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpCommands.ts b/apps/server/src/provider/Drivers/OmpCommands.ts index 17f4d96131d7..2960cfd4b49b 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -80,18 +80,62 @@ function commandEntriesFromFrame(frame: Record): ReadonlyArray< } /** - * Split an RPC `available_commands_update` frame into provider skills and - * slash commands. Skill `path` carries omp's own `skill://` URL: the runtime - * resolves a skill by name through several roots and the command list does not - * report the file it came from, so there is no filesystem path to hand back. + * Fold raw `available_commands_update` entries into provider skills and slash + * commands. Shared by the RPC startup probe (`decodeOmpCommandCatalog`) and + * the live session path: the adapter forwards `available_commands_update` + * payloads verbatim (including `skill:`-prefixed entries) through + * `onSessionCommands`, and the driver maps them here so both catalogs split + * identically. Skill `path` carries omp's own `skill://` URL: the runtime + * resolves a skill by name through several roots and the command list does + * not report the file it came from, so there is no filesystem path to hand + * back. * * Subcommands stay folded into their parent: T3's composer has no nested * commands, and omp's parent entry already advertises them through its input * hint (`/security `). */ -export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { +export function catalogFromCommandEntries(entries: ReadonlyArray): OmpCommandCatalog { const skillsByName = new Map(); const commandsByName = new Map(); + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const command = entry as Record; + const commandName = trimmedString(command.name); + if (commandName.length === 0) continue; + const description = trimmedString(command.description); + if (commandName.startsWith(SKILL_COMMAND_PREFIX)) { + const name = commandName.slice(SKILL_COMMAND_PREFIX.length).trim(); + if (name.length === 0) continue; + skillsByName.set(name, { + name, + path: `skill://${name}/SKILL.md`, + enabled: true, + ...(description.length > 0 ? { description } : {}), + }); + continue; + } + const hint = trimmedString((command.input as Record | undefined)?.hint); + commandsByName.set(commandName, { + name: commandName, + ...(description.length > 0 ? { description } : {}), + ...(hint.length > 0 ? { input: { hint } } : {}), + }); + } + return { + skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), + slashCommands: [...commandsByName.values()].sort((left, right) => + left.name.localeCompare(right.name), + ), + }; +} + +/** + * Split an RPC `available_commands_update` frame into provider skills and + * slash commands. Parses the JSONL transport, then folds every frame's + * entries through {@link catalogFromCommandEntries}. + */ +export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { + const entries: Array = []; for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) continue; @@ -104,36 +148,10 @@ export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { } if (typeof frame !== "object" || frame === null) continue; for (const entry of commandEntriesFromFrame(frame as Record)) { - if (typeof entry !== "object" || entry === null) continue; - const command = entry as Record; - const commandName = trimmedString(command.name); - if (commandName.length === 0) continue; - const description = trimmedString(command.description); - if (commandName.startsWith(SKILL_COMMAND_PREFIX)) { - const name = commandName.slice(SKILL_COMMAND_PREFIX.length).trim(); - if (name.length === 0) continue; - skillsByName.set(name, { - name, - path: `skill://${name}/SKILL.md`, - enabled: true, - ...(description.length > 0 ? { description } : {}), - }); - continue; - } - const hint = trimmedString((command.input as Record | undefined)?.hint); - commandsByName.set(commandName, { - name: commandName, - ...(description.length > 0 ? { description } : {}), - ...(hint.length > 0 ? { input: { hint } } : {}), - }); + entries.push(entry); } } - return { - skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), - slashCommands: [...commandsByName.values()].sort((left, right) => - left.name.localeCompare(right.name), - ), - }; + return catalogFromCommandEntries(entries); } /** diff --git a/apps/server/src/provider/Drivers/OmpDriver.test.ts b/apps/server/src/provider/Drivers/OmpDriver.test.ts index 5039ab4e8719..7de0908cbc12 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.test.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.test.ts @@ -16,8 +16,19 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { createProviderVersionAdvisory } from "../providerMaintenance.ts"; import { writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { vi } from "vite-plus/test"; import { OmpDriver } from "./OmpDriver.ts"; +const capturedAdapterOptions = vi.hoisted(() => [] as Array); + +vi.mock("../Layers/OmpAdapter.ts", async (importOriginal) => { + const actual = await importOriginal(); + const makeOmpAdapter = (...args: Parameters) => { + capturedAdapterOptions.push(args[1]); + return actual.makeOmpAdapter(...args); + }; + return { ...actual, makeOmpAdapter }; +}); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-omp-driver-maintenance-", }).pipe( @@ -57,9 +68,10 @@ function fakeOmpSource(input: { readonly mockAgentPath: string; readonly checkOutput: string; readonly ompShapesEnv: string; + readonly probeLogPath?: string; }): string { return [ - 'import { existsSync } from "node:fs";', + 'import { appendFileSync, existsSync } from "node:fs";', 'import { pathToFileURL } from "node:url";', "const args = process.argv.slice(2);", 'if (args[0] === "--version") {', @@ -71,6 +83,9 @@ function fakeOmpSource(input: { " process.exit(0);", "}", 'if (args[0] === "--mode") {', + ...(input.probeLogPath + ? [` appendFileSync(${JSON.stringify(input.probeLogPath)}, "probe\\n");`] + : []), ` process.stdout.write(${JSON.stringify(`${JSON.stringify({ type: "available_commands_update", commands: catalogCommands })}\n`)});`, " process.exit(0);", "}", @@ -89,6 +104,7 @@ const makeFakeOmp = Effect.fn("makeFakeOmp")(function* (options: { readonly prefix: string; readonly checkOutput: string; readonly ompShapesEnv?: string; + readonly probeLogPath?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const mockAgentPath = yield* resolveMockAgentPath(); @@ -100,6 +116,7 @@ const makeFakeOmp = Effect.fn("makeFakeOmp")(function* (options: { mockAgentPath, checkOutput: options.checkOutput, ompShapesEnv: options.ompShapesEnv ?? 'process.env.T3_ACP_OMP_SHAPES = "1";', + ...(options.probeLogPath ? { probeLogPath: options.probeLogPath } : {}), }), }); }); @@ -116,6 +133,30 @@ const createTestInstance = ( config: { ...OmpDriver.defaultConfig(), binaryPath: input.binaryPath }, }); +interface CapturedOmpAdapterOptions { + readonly resolveSkillNames?: (cwd: string) => ReadonlySet; + readonly onSessionCommands?: ( + cwd: string, + commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly input?: { readonly hint: string }; + }>, + ) => void; +} + +const lastAdapterOptions = (): CapturedOmpAdapterOptions | undefined => + capturedAdapterOptions.at(-1) as CapturedOmpAdapterOptions | undefined; + +const readProbeCount = (probeLogPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const text = yield* fileSystem + .readFileString(probeLogPath) + .pipe(Effect.orElseSucceed(() => "")); + return text.split("\n").filter((line) => line.length > 0).length; + }); + it.layer(testLayer)("OmpDriver", (it) => { it.effect("advertises omp's own update command with the --check latest version", () => Effect.gen(function* () { @@ -264,4 +305,102 @@ it.layer(testLayer)("OmpDriver", (it) => { expect(after).not.toEqual(before); }).pipe(Effect.scoped), ); + + it.effect("reuses the probed catalog for a repeat snapshot inside the freshness window", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-driver-cache-" }); + const probeLogPath = path.join(root, "probes.log"); + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-cache-bin-", + checkOutput: "Current version: 18.1.18\n", + probeLogPath, + }); + const instance = yield* createTestInstance("omp-catalog-cache", { + binaryPath: fakePath, + enabled: true, + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) + return yield* Effect.die("OmpDriver does not expose workspace snapshots."); + const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-cache-ws-" }); + const first = yield* snapshotForCwd(workspace); + const second = yield* snapshotForCwd(workspace); + expect(second.skills).toEqual(first.skills); + expect(second.slashCommands).toEqual(first.slashCommands); + expect(yield* readProbeCount(probeLogPath)).toBe(1); + + const other = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-cache-other-" }); + yield* snapshotForCwd(other); + expect(yield* readProbeCount(probeLogPath)).toBe(2); + }).pipe(Effect.scoped), + ); + + it.effect("applies a live available_commands_update without a second probe", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-driver-live-" }); + const probeLogPath = path.join(root, "probes.log"); + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-live-bin-", + checkOutput: "Current version: 18.1.18\n", + probeLogPath, + }); + const instance = yield* createTestInstance("omp-live-commands", { + binaryPath: fakePath, + enabled: true, + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) + return yield* Effect.die("OmpDriver does not expose workspace snapshots."); + const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-live-ws-" }); + const first = yield* snapshotForCwd(workspace); + expect(first.skills.map((skill) => skill.name)).toEqual(["deploy"]); + expect(yield* readProbeCount(probeLogPath)).toBe(1); + + const options = lastAdapterOptions(); + const onSessionCommands = options?.onSessionCommands; + if (!onSessionCommands) + return yield* Effect.die("OmpDriver did not pass onSessionCommands to the adapter."); + onSessionCommands(workspace, [ + { name: "skill:fresh", description: "Freshly installed skill" }, + { name: "newcmd", description: "New command", input: { hint: "" } }, + ]); + // The `$mention` skill set refreshes with the live payload, no turn needed. + expect(options?.resolveSkillNames?.(workspace)).toEqual(new Set(["fresh"])); + + const second = yield* snapshotForCwd(workspace); + expect(second.skills.map((skill) => skill.name)).toEqual(["fresh"]); + expect(second.slashCommands).toEqual([ + { name: "newcmd", description: "New command", input: { hint: "" } }, + ]); + expect( + second.workspaceSnapshots + ?.find((entry) => entry.cwd === workspace) + ?.skills.map((skill) => skill.name), + ).toEqual(["fresh"]); + // The live payload replaced the cached probe instead of re-spawning it. + expect(yield* readProbeCount(probeLogPath)).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the omp advisory on update --check with no registry fallback", () => + Effect.gen(function* () { + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-advisory-", + checkOutput: "Current version: 18.1.18\nNew version available: 18.1.21\n", + }); + const instance = yield* createTestInstance("omp-advisory-source", { + binaryPath: fakePath, + enabled: false, + }); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + // A null packageName leaves the npm latest-version path unreachable, so + // the --check latest below is the only version the UI can show. + expect(capabilities.packageName).toBeNull(); + expect(capabilities.latestVersion).toBe("18.1.21"); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts index 1ccb04c5c4f7..b825e3e00b18 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -38,7 +38,11 @@ import { checkOmpProviderStatus, enrichOmpSnapshot, } from "../Layers/OmpProvider.ts"; -import { discoverOmpCommandCatalog } from "./OmpCommands.ts"; +import { + catalogFromCommandEntries, + discoverOmpCommandCatalog, + type OmpCommandCatalog, +} from "./OmpCommands.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -63,6 +67,31 @@ const decodeOmpSettings = Schema.decodeSync(OmpSettings); const DRIVER_KIND = ProviderDriverKind.make("omp"); +/** + * How long a probed per-cwd command catalog is reused without re-spawning + * `omp --mode rpc`. The registry already calls `snapshotForCwd` at most once + * per cwd per provider list (plus one in-flight dedup), so the remaining + * repeats are bursts — a turn start forking a workspace refresh while the + * composer opens the same cwd. Thirty seconds absorbs those bursts while + * bounding how stale an out-of-band install (no live session to announce it) + * can look. A live `available_commands_update` replaces the entry, so + * in-session installs surface immediately regardless of the window. + */ +export const OMP_COMMAND_CATALOG_FRESHNESS_MS = 30_000; + +/** + * One raw `available_commands_update` entry as the adapter forwards it: + * verbatim from omp, `skill:` prefix untouched. Mirrors the batch contract + * (`OmpAdapterLiveOptions.onSessionCommands`, owned by `OmpSessionLifecycle`); + * the `as` cast at the adapter call site keeps this compiling until that + * field lands. + */ +export interface OmpSessionCommandEntry { + readonly name: string; + readonly description?: string; + readonly input?: { readonly hint: string }; +} + export type OmpDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner @@ -137,11 +166,48 @@ export const OmpDriver: ProviderDriver = { // Skills discovered per workspace. The adapter reads names from here to // rewrite `$name` mentions, so a turn never spawns its own probe. const skillNamesByCwd = new Map>(); + // Last catalog per cwd with its probe time. A repeat snapshot inside + // the freshness window reuses it instead of re-spawning the RPC probe. + const catalogCacheByCwd = new Map< + string, + { readonly catalog: OmpCommandCatalog; readonly cachedAt: number } + >(); + // Per-workspace command catalogs. The composer only offers a workspace's + // menus once its snapshot is recorded, so every refresh retains the + // workspaces visited earlier in the session (Antigravity precedent). + let retainedWorkspaceSnapshots: ReadonlyArray = []; + const rememberCatalog = ( + workspaceCwd: string, + catalog: OmpCommandCatalog, + checkedAt: string, + ): void => { + skillNamesByCwd.set(workspaceCwd, new Set(catalog.skills.map((skill) => skill.name))); + // @effect-diagnostics-next-line globalDate:off - cache stamp shares Date.now with the freshness read below; Effect Clock is unavailable in the sync callback path. + catalogCacheByCwd.set(workspaceCwd, { catalog, cachedAt: Date.now() }); + retainedWorkspaceSnapshots = appendOmpWorkspaceSnapshot(retainedWorkspaceSnapshots, { + cwd: workspaceCwd, + checkedAt, + slashCommands: catalog.slashCommands, + skills: catalog.skills, + }); + }; + // Live `available_commands_update` entries from the adapter, folded + // through the same `skill:` split as the RPC probe. Synchronous by + // contract, so it records the catalog for the next `snapshotForCwd` + // pull and refreshes the `$mention` skill set immediately. + const onSessionCommands = ( + cwd: string, + commands: ReadonlyArray, + ): void => { + // @effect-diagnostics-next-line globalDate:off - `onSessionCommands` is sync void by contract, so no Effect Clock; ISO format matches DateTime.formatIso. + rememberCatalog(cwd, catalogFromCommandEntries(commands), new Date().toISOString()); + }; const adapter = yield* makeOmpAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, resolveSkillNames: (cwd) => skillNamesByCwd.get(cwd) ?? new Set(), + onSessionCommands, }); const textGeneration = yield* makeOmpTextGeneration(effectiveConfig, processEnv); @@ -187,56 +253,57 @@ export const OmpDriver: ProviderDriver = { }), ), ); - // Per-workspace command catalogs. The composer only offers a workspace's - // menus once its snapshot is recorded, so every refresh retains the - // workspaces visited earlier in the session (Antigravity precedent). - let retainedWorkspaceSnapshots: ReadonlyArray = []; - const snapshotForCwd = (workspaceCwd: string) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - discoverOmpCommandCatalog(effectiveConfig, processEnv, workspaceCwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to discover Oh My Pi commands for '${workspaceCwd}'`, - cause, - }), - ), - ), - ]).pipe( - Effect.tap(([, catalog]) => - Effect.sync(() => { - skillNamesByCwd.set( - workspaceCwd, - new Set(catalog.skills.map((skill) => skill.name)), - ); - }), - ), - Effect.flatMap(([machineSnapshot, catalog]) => - Effect.map(DateTime.now, (now) => { - retainedWorkspaceSnapshots = appendOmpWorkspaceSnapshot( - retainedWorkspaceSnapshots, - { - cwd: workspaceCwd, - checkedAt: DateTime.formatIso(now), - slashCommands: catalog.slashCommands, - skills: catalog.skills, - }, - ); - return { - ...machineSnapshot, - skills: catalog.skills, - slashCommands: catalog.slashCommands, - workspaceSnapshots: [...retainedWorkspaceSnapshots], - }; + const snapshotForCwd = ( + workspaceCwd: string, + ): Effect.Effect => { + if (!effectiveConfig.enabled) return snapshot.getSnapshot; + const cached = catalogCacheByCwd.get(workspaceCwd); + // @effect-diagnostics-next-line globalDate:off - freshness read on the same Date.now clock as the cache stamp. + if (cached && Date.now() - cached.cachedAt < OMP_COMMAND_CATALOG_FRESHNESS_MS) { + return snapshot.getSnapshot.pipe( + Effect.flatMap((machineSnapshot) => + Effect.map(DateTime.now, (now) => { + // Re-record so a revisited cwd moves last and an evicted one + // comes back; the timestamp slides while the cwd stays hot. + rememberCatalog(workspaceCwd, cached.catalog, DateTime.formatIso(now)); + return { + ...machineSnapshot, + skills: cached.catalog.skills, + slashCommands: cached.catalog.slashCommands, + workspaceSnapshots: [...retainedWorkspaceSnapshots], + }; + }), + ), + ); + } + return Effect.all([ + snapshot.getSnapshot, + discoverOmpCommandCatalog(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Oh My Pi commands for '${workspaceCwd}'`, + cause, }), - ), - ); + ), + ), + ]).pipe( + Effect.flatMap(([machineSnapshot, catalog]) => + Effect.map(DateTime.now, (now) => { + rememberCatalog(workspaceCwd, catalog, DateTime.formatIso(now)); + return { + ...machineSnapshot, + skills: catalog.skills, + slashCommands: catalog.slashCommands, + workspaceSnapshots: [...retainedWorkspaceSnapshots], + }; + }), + ), + ); + }; // A user who configures a new upstream inside omp re-probes the catalog // without restarting T3: the managed refresh re-runs the ACP discovery diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 9e79b7acfafe..498762026b94 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -160,6 +160,19 @@ const makeResolveOmpSettings = Effect.gen(function* () { ); }); +interface SessionCommandCall { + readonly cwd: string; + readonly commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly input?: { readonly hint: string }; + }>; +} + +// The suite shares one adapter, so the callback records into a module-level +// sink each test drains for its own thread. +const sessionCommandCalls: Array = []; + const makeOmpAdapterTestLayer = (instanceId?: ProviderInstanceId) => Layer.effect( OmpAdapter, @@ -170,6 +183,9 @@ const makeOmpAdapterTestLayer = (instanceId?: ProviderInstanceId) => ...(instanceId ? { instanceId } : {}), resolveSettings, resolveSkillNames: () => new Set(["tdd"]), + onSessionCommands: (cwd, commands) => { + sessionCommandCalls.push({ cwd, commands }); + }, }); }), ).pipe( @@ -2711,4 +2727,371 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("forks the live session into a new omp session and leaves it usable", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-fork-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_OMP_SESSION_STORE: "1" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const cursor = session.resumeCursor; + assert.isTrue( + typeof cursor === "object" && cursor !== null && "sessionId" in cursor, + "startSession must persist an omp resume cursor", + ); + const originalSessionId = + typeof cursor === "object" && cursor !== null && "sessionId" in cursor + ? String(cursor.sessionId) + : ""; + + const fork = yield* adapter.forkSession(threadId); + assert.notEqual(fork.sessionId, originalSessionId); + assert.equal(fork.sessionId, `${originalSessionId}-fork-1`); + assert.deepStrictEqual(fork.resumeCursor, { schemaVersion: 1, sessionId: fork.sessionId }); + assert.equal(fork.cwd, session.cwd); + + // The fork is a copy: the original thread keeps running on its own + // session id and can still take a turn. + const turn = yield* adapter.sendTurn({ threadId, input: "still alive", attachments: [] }); + assert.equal(String(turn.threadId), String(threadId)); + assert.deepStrictEqual( + (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId)?.resumeCursor, + { schemaVersion: 1, sessionId: originalSessionId }, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refuses to fork a thread with no live omp session", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const exit = yield* Effect.exit(adapter.forkSession(ThreadId.make("omp-fork-missing"))); + assert.isTrue(Exit.isFailure(exit)); + }), + ); + + // The migrating-terminal-user path: no session is open here, so discovery + // has to stand up its own connection. + it.effect("discovers omp's own sessions without a live session and resumes one", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_OMP_SESSION_STORE: "1", + T3_ACP_SESSION_LIST_CWD: process.cwd(), + }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const page = yield* adapter.listNativeSessions({ cwd: process.cwd() }); + assert.equal(page.nextCursor, "mock-cursor-2"); + assert.equal(page.skippedCount, 0); + assert.deepStrictEqual( + page.sessions.map((entry) => entry.sessionId), + ["omp-session-terminal-1", "omp-session-elsewhere-1"], + ); + const terminalSession = page.sessions[0]; + assert.isDefined(terminalSession); + assert.equal(terminalSession?.title, "Terminal session"); + assert.equal(terminalSession?.updatedAt, "2026-02-03T04:05:06.000Z"); + assert.equal(terminalSession?.cwd, process.cwd()); + assert.equal(terminalSession?.messageCount, 12); + assert.deepStrictEqual(terminalSession?.resumeCursor, { + schemaVersion: 1, + sessionId: "omp-session-terminal-1", + }); + + // A session from another workspace is still listed; the filter is the + // caller's choice, not an implicit one. + const filtered = yield* adapter.listNativeSessions({ + cwd: process.cwd(), + filterCwd: process.cwd(), + }); + assert.deepStrictEqual( + filtered.sessions.map((entry) => entry.sessionId), + ["omp-session-terminal-1"], + ); + + const threadId = ThreadId.make("omp-resume-discovered-thread"); + const resumed = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: terminalSession?.resumeCursor, + }); + assert.deepStrictEqual(resumed.resumeCursor, { + schemaVersion: 1, + sessionId: "omp-session-terminal-1", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("forwards omp's session command catalog with its skill entries intact", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-session-commands-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_SESSION_COMMANDS: "1" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + sessionCommandCalls.length = 0; + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] }); + + const call = sessionCommandCalls.at(-1); + assert.isDefined(call); + assert.equal(call?.cwd, NodePath.resolve(process.cwd())); + assert.deepStrictEqual(call?.commands, [ + { name: "compact", description: "Compact the context" }, + { + name: "skill:tdd", + description: "Test-driven development", + input: { hint: "task" }, + }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("publishes omp's live subagent progress and its settled result", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-task-progress-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EMIT_TASK_TOOL: "1", + T3_ACP_EMIT_TASK_TOOL_PROGRESS: "1", + }), + ); + 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", + }); + yield* adapter.sendTurn({ threadId, input: "spawn a subagent", attachments: [] }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const updates = runtimeEvents.filter((event) => event.type === "task.updated"); + const progress = runtimeEvents.filter((event) => event.type === "task.progress"); + + assert.lengthOf(updates, 1); + const update = updates[0]; + if (update?.type === "task.updated") { + assert.equal(String(update.payload.taskId), "task-tool-call-1"); + assert.equal(update.payload.status, "running"); + assert.equal(update.payload.title, "Implement the feature"); + } + + // Two in-flight ticks reach the adapter but carry the same subagent + // state, so only the first becomes a row. + assert.lengthOf(progress, 1); + const row = progress[0]; + if (row?.type === "task.progress") { + assert.equal(String(row.payload.taskId), "task-tool-call-1"); + assert.equal(row.payload.description, "Reading the adapter"); + assert.equal(row.payload.lastToolName, "read"); + assert.equal(row.payload.status, "running"); + assert.equal(row.payload.model, "anthropic/claude-sonnet"); + assert.deepStrictEqual(row.payload.typedUsage, { + totalTokens: 1200, + toolUses: 4, + durationMs: 1200, + }); + } + + const completed = runtimeEvents.find((event) => event.type === "task.completed"); + assert.isDefined(completed); + if (completed?.type === "task.completed") { + assert.equal(completed.payload.status, "completed"); + assert.equal(completed.payload.summary, "subagent finished the work"); + assert.deepStrictEqual(completed.payload.typedUsage, { + totalTokens: 3400, + durationMs: 4800, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + // Without a per-agent progress payload there is nothing to report: the + // adapter must not invent rows between start and completion. + it.effect("emits no task progress when omp reports no subagent state", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-task-no-progress-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TASK_TOOL: "1" }), + ); + 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", + }); + yield* adapter.sendTurn({ threadId, input: "spawn a subagent", attachments: [] }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "task.progress" || event.type === "task.updated", + ), + 0, + ); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "task.completed"), + 1, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + // A model omp does not offer is answered by whatever the session has + // configured. The turn still succeeds, so the substitution has to be said + // out loud or the user never learns which model replied. + it.effect("warns once per turn when a different model answers than requested", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-model-substitution-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.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "anthropic/claude-fable-5", + }, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + assert.lengthOf(warnings, 1); + const warning = warnings[0]; + if (warning?.type === "runtime.warning") { + assert.include(warning.payload.message, "anthropic/claude-fable-5"); + assert.include(warning.payload.message, "zhipu-coding-plan/glm-5.3"); + assert.deepStrictEqual(warning.payload.detail, { + requestedModel: "anthropic/claude-fable-5", + effectiveModel: "zhipu-coding-plan/glm-5.3", + }); + } + const completion = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal( + completion?.type === "turn.completed" ? completion.payload.state : undefined, + "completed", + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stays silent when the requested model is the one that answers", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-model-advertised-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.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "openai/gpt-5.4", + }, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + 0, + ); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index 0fdcc24ebc5e..db8a3330fb7f 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -20,6 +20,8 @@ import { RuntimeRequestId, RuntimeTaskId, type RuntimeMode, + type RuntimeTaskStatus, + type RuntimeTaskUsage, type ThreadId, type ThreadTokenUsageSnapshot, TurnId, @@ -74,6 +76,8 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyOmpAcpModelSelection, makeOmpAcpRuntime, + parseOmpForkedSessionId, + parseOmpSessionList, resolveOmpAcpBaseModelId, } from "../acp/OmpAcpSupport.ts"; import { type OmpAdapterShape } from "../Services/OmpAdapter.ts"; @@ -120,6 +124,21 @@ export interface OmpAdapterLiveOptions { * empty set leave the prompt untouched. */ readonly resolveSkillNames?: (cwd: string) => ReadonlySet; + /** + * Receives omp's `available_commands_update` for a session: the session + * cwd and the raw command entries, skill entries and all. The driver + * folds them into the per-cwd catalog its startup probe built, so a + * command (or skill) added while a session is open reaches the composer + * without a new discovery process. + */ + readonly onSessionCommands?: ( + cwd: string, + commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly input?: { readonly hint: string }; + }>, + ) => void; } interface PendingApproval { @@ -147,7 +166,13 @@ interface OmpSessionContext { /** 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>; + /** Last emitted subagent snapshot per task id. omp repeats every agent's + * full state on each tick, so only material changes become events. */ + readonly ompSubagentActivity: Map; readonly turns: Array<{ id: TurnId; items: Array }>; + /** Turn that already reported a model substitution, so a steer folded + * into the same turn does not repeat the warning. */ + modelWarningTurnId: TurnId | undefined; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; /** Context occupancy last reported by omp's `usage_update`. The @@ -571,16 +596,140 @@ export function parseOmpSubagentSpawns( return single ? [single] : []; } +/** + * omp's task tool reports through the ordinary tool-call payload: its + * result object is `{ content: [...], details: { progress?, results? } }`. + * `content[].text` is the human summary, so a summary lookup has to reach + * into the content blocks, not only flat string fields. + */ 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; + if (typeof rawOutput === "string") { + return rawOutput.trim() ? truncateTaskText(rawOutput, OMP_TASK_RESULT_MAX_CHARS) : undefined; + } + if (!isRecord(rawOutput)) { + return undefined; + } + const flat = ["output", "result", "text", "stdout"] + .map((field) => rawOutput[field]) + .find((value): value is string => typeof value === "string" && value.trim().length > 0); + if (flat) { + return truncateTaskText(flat, OMP_TASK_RESULT_MAX_CHARS); + } + const content = rawOutput.content; + if (typeof content === "string" && content.trim().length > 0) { + return truncateTaskText(content, OMP_TASK_RESULT_MAX_CHARS); + } + if (!Array.isArray(content)) { + return undefined; + } + const blockText = content + .flatMap((block) => (isRecord(block) && typeof block.text === "string" ? [block.text] : [])) + .join("\n"); + return blockText.trim() ? truncateTaskText(blockText, OMP_TASK_RESULT_MAX_CHARS) : undefined; +} + +/** + * One omp subagent's state inside a `task` tool-call payload. omp streams + * `details.progress` while agents run and `details.results` once they + * settle; both are per-spawn arrays carrying the same identity fields, so + * they are projected onto one shape. + */ +interface OmpSubagentActivity { + readonly index: number | undefined; + readonly status: RuntimeTaskStatus | undefined; + /** `lastIntent` while running, the spawn's own description otherwise. */ + readonly description: string | undefined; + readonly lastToolName: string | undefined; + readonly summary: string | undefined; + readonly error: string | undefined; + readonly model: string | undefined; + readonly usage: RuntimeTaskUsage | undefined; +} + +const OMP_SUBAGENT_STATUSES: Record = { + pending: "pending", + running: "running", + completed: "completed", + failed: "failed", + // omp aborts a subagent on interrupt or runtime cap; T3's vocabulary + // calls that cancelled. + aborted: "cancelled", +}; + +function parseOmpSubagentActivity(entry: Record): OmpSubagentActivity { + const reportedStatus = + typeof entry.status === "string" ? OMP_SUBAGENT_STATUSES[entry.status] : undefined; + const error = optionalTrimmedString(entry.error); + const exitCode = typeof entry.exitCode === "number" ? entry.exitCode : undefined; + // Settled entries (`details.results`) carry no status field: omp encodes + // the outcome as aborted / exitCode / error instead. + const settledStatus = + entry.aborted === true + ? ("cancelled" as const) + : exitCode === undefined + ? undefined + : exitCode === 0 && error === undefined + ? ("completed" as const) + : ("failed" as const); + const recentTools = Array.isArray(entry.recentTools) ? entry.recentTools : []; + const lastTool = recentTools.find( + (tool): tool is Record => isRecord(tool) && typeof tool.tool === "string", + ); + const totalTokens = nonNegativeTokenCount( + typeof entry.tokens === "number" ? entry.tokens : undefined, + ); + const toolUses = nonNegativeTokenCount( + typeof entry.toolCount === "number" ? entry.toolCount : undefined, + ); + const durationMs = nonNegativeTokenCount( + typeof entry.durationMs === "number" ? entry.durationMs : undefined, + ); + const output = optionalTrimmedString(entry.output); + return { + index: typeof entry.index === "number" && entry.index >= 0 ? entry.index : undefined, + status: reportedStatus ?? settledStatus, + description: + optionalTrimmedString(entry.lastIntent) ?? optionalTrimmedString(entry.description), + lastToolName: + optionalTrimmedString(entry.currentTool) ?? + (lastTool ? optionalTrimmedString(lastTool.tool) : undefined), + summary: output + ? truncateTaskText(output, OMP_TASK_RESULT_MAX_CHARS) + : error + ? truncateTaskText(error, OMP_TASK_RESULT_MAX_CHARS) + : undefined, + error: error ? truncateTaskText(error, OMP_TASK_RESULT_MAX_CHARS) : undefined, + model: optionalTrimmedString(entry.resolvedModel), + usage: + totalTokens === undefined + ? undefined + : { + totalTokens, + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }, + }; +} + +/** + * Splits a task tool-call payload into the in-flight and settled subagent + * entries omp reported. Anything else in `details` (agent directories, + * aggregate usage, output paths) belongs to the tool row, not the Agents + * panel. + */ +export function parseOmpSubagentActivities(rawOutput: unknown): { + readonly progress: ReadonlyArray; + readonly results: ReadonlyArray; +} { + const details = isRecord(rawOutput) ? rawOutput.details : undefined; + if (!isRecord(details)) { + return { progress: [], results: [] }; + } + const read = (value: unknown) => + Array.isArray(value) + ? value.flatMap((entry) => (isRecord(entry) ? [parseOmpSubagentActivity(entry)] : [])) + : []; + return { progress: read(details.progress), results: read(details.results) }; } function nonNegativeTokenCount(value: number | null | undefined): number | undefined { @@ -784,8 +933,13 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv /** * 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. + * shows its Task tool in the timeline as well). + * + * omp carries live subagent state inside the task tool's own payload: + * `rawOutput.details.progress` while agents run, `details.results` once + * they settle. Those arrays are the only subagent activity omp reports + * over ACP, so every richer event below comes from them and nothing is + * synthesized between ticks. */ const emitOmpSubagentEvents = (ctx: OmpSessionContext, toolCall: AcpToolCallState) => Effect.gen(function* () { @@ -816,12 +970,114 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv } } - if (toolCall.status !== "completed" && toolCall.status !== "failed") { + const spawns = tracked; + // omp keys its entries by spawn index; a single spawn still reports + // index 0 while its task id is the bare tool call id, so position in + // the tracked array — not the reported index — resolves identity. + const spawnFor = (activity: OmpSubagentActivity) => + spawns[activity.index ?? 0] ?? (spawns.length === 1 ? spawns[0] : undefined); + const activities = parseOmpSubagentActivities(toolCall.data.rawOutput); + const terminal = toolCall.status === "completed" || toolCall.status === "failed"; + + for (const activity of activities.progress) { + const spawn = spawnFor(activity); + if (!spawn) { + continue; + } + // omp repeats every agent's full snapshot on each tick; without a + // material-change filter a fan-out of N agents costs N events per + // tick for state the client already renders. + const fingerprint = [ + activity.status ?? "", + activity.description ?? "", + activity.lastToolName ?? "", + activity.error ?? "", + activity.model ?? "", + activity.usage?.totalTokens ?? "", + activity.usage?.toolUses ?? "", + ].join("\u001f"); + if (ctx.ompSubagentActivity.get(spawn.taskId) === fingerprint) { + continue; + } + const previous = ctx.ompSubagentActivity.get(spawn.taskId); + ctx.ompSubagentActivity.set(spawn.taskId, fingerprint); + const linkage = { + taskType: "subagent" as const, + title: spawn.title, + ...(spawn.role ? { role: spawn.role } : {}), + ...(spawn.effort ? { effort: spawn.effort } : {}), + ...(activity.model ? { model: activity.model } : {}), + toolUseId: toolCall.toolCallId, + }; + // A status-only tick is a status patch, not an activity row: the + // Agents panel renders the two differently and a progress row with + // no description would render blank. + if (activity.status !== undefined && previous?.split("\u001f")[0] !== activity.status) { + yield* offerRuntimeEvent({ + type: "task.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(spawn.taskId), + status: activity.status, + ...(activity.description ? { description: activity.description } : {}), + ...(activity.error ? { error: activity.error } : {}), + ...linkage, + }, + }); + } + if (activity.description === undefined) { + continue; + } + yield* offerRuntimeEvent({ + type: "task.progress", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(spawn.taskId), + description: activity.description, + ...(activity.status ? { status: activity.status } : {}), + ...(activity.lastToolName ? { lastToolName: activity.lastToolName } : {}), + ...(activity.error ? { error: activity.error } : {}), + ...(activity.usage ? { typedUsage: activity.usage } : {}), + ...linkage, + }, + }); + } + + if (!terminal) { return; } ctx.ompSubagentTasks.delete(toolCall.toolCallId); const summary = summarizeOmpTaskResult(toolCall.data.rawOutput); - for (const spawn of tracked) { + const resultFor = new Map(); + for (const result of activities.results) { + const spawn = spawnFor(result); + if (spawn) { + resultFor.set(spawn.taskId, result); + } + } + for (const spawn of spawns) { + ctx.ompSubagentActivity.delete(spawn.taskId); + const result = resultFor.get(spawn.taskId); + // Per-agent outcome wins over the tool call's: one failed agent in + // a fan-out must not mark its siblings failed, and a fan-out that + // fails overall must not report a succeeded agent as failed. + const status = + result?.status === "cancelled" + ? ("stopped" as const) + : result?.status === "failed" + ? ("failed" as const) + : result?.status === "completed" + ? ("completed" as const) + : toolCall.status === "failed" + ? ("failed" as const) + : ("completed" as const); + const resolvedSummary = result?.summary ?? summary; yield* offerRuntimeEvent({ type: "task.completed", ...(yield* makeEventStamp()), @@ -830,12 +1086,14 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv turnId: ctx.activeTurnId, payload: { taskId: RuntimeTaskId.make(spawn.taskId), - status: toolCall.status, - ...(summary ? { summary } : {}), + status, + ...(resolvedSummary ? { summary: resolvedSummary } : {}), + ...(result?.usage ? { typedUsage: result.usage } : {}), taskType: "subagent", title: spawn.title, ...(spawn.role ? { role: spawn.role } : {}), ...(spawn.effort ? { effort: spawn.effort } : {}), + ...(result?.model ? { model: result.model } : {}), toolUseId: toolCall.toolCallId, }, }); @@ -1129,27 +1387,59 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv } return runElicitationFlow("elicitation/create", params, params); }); - // Side channel for the two session/update kinds the shared ACP - // parser drops: `usage_update` (context meter) and a `plan` with - // zero entries (omp's todo_auto_clear, which the todo panel - // reads as "clear the plan"). Handlers are additive, so the - // runtime's own parser still owns every other update kind; this - // one drains the runtime's event queue first so a clear can - // never overtake the plan it clears. + // Side channel for the session/update kinds the shared ACP + // parser drops or routes elsewhere: `usage_update` (context + // meter), a `plan` with zero entries (omp's todo_auto_clear, + // which the todo panel reads as "clear the plan"), and + // `available_commands_update` (the driver's per-cwd command + // catalog). Handlers are additive, so the runtime's own parser + // still owns every other update kind; this one drains the + // runtime's event queue first so a clear can never overtake the + // plan it clears. yield* acp.handleSessionUpdate((notification) => Effect.gen(function* () { const sessionCtx = sessions.get(input.threadId); + const update = notification.update; + if (rootSessionId === undefined || notification.sessionId !== rootSessionId) { + return; + } + // The command catalog is session state, not turn content: + // it arrives before `sessions` has the context (omp + // publishes it during session setup) and a replayed copy on + // session/load is still the current catalog, so it is + // forwarded under a looser gate than the timeline kinds. + if (update.sessionUpdate === "available_commands_update") { + if (sessionCtx?.stopped === true) { + return; + } + yield* logNative(input.threadId, "session/update", notification); + yield* Effect.sync(() => + options?.onSessionCommands?.( + cwd, + // Names keep omp's own prefixes (`skill:` included); + // only optional fields are narrowed to the shape the + // driver's catalog reads. + update.availableCommands.map((command) => ({ + name: command.name, + ...(typeof command.description === "string" + ? { description: command.description } + : {}), + ...(command.input && typeof command.input.hint === "string" + ? { input: { hint: command.input.hint } } + : {}), + })), + ), + ); + return; + } if ( sessionCtx === undefined || sessionCtx.stopped || sessionCtx.acp !== acp || - rootSessionId === undefined || - notification.sessionId !== rootSessionId || sessionUpdateIsReplay(notification) ) { return; } - const update = notification.update; if (update.sessionUpdate === "usage_update") { yield* logNative(sessionCtx.threadId, "session/update", notification); sessionCtx.lastContextUsedTokens = @@ -1230,9 +1520,11 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv pendingUserInputs, cancelledTurnIds: new Set(), ompSubagentTasks: new Map(), + ompSubagentActivity: new Map(), turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + modelWarningTurnId: undefined, lastContextUsedTokens: undefined, lastContextWindowTokens: undefined, promptsInFlight: 0, @@ -1559,6 +1851,33 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ); const effectiveModel = configuration.model ?? resolvedModel; + // omp keeps its configured model when the requested slug is not + // advertised by the session (see applyOmpAcpModelSelection): the + // turn still runs, but a different model answers it. Say so once + // per turn instead of letting the substitution pass silently. + if ( + resolvedModel !== undefined && + configuration.model !== resolvedModel && + ctx.modelWarningTurnId !== turnId + ) { + ctx.modelWarningTurnId = turnId; + const answering = configuration.model ?? "its configured model"; + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + message: `Oh My Pi does not offer '${resolvedModel}' in this session and answered with ${answering} instead.`, + detail: { + requestedModel: resolvedModel, + ...(configuration.model ? { effectiveModel: configuration.model } : {}), + }, + }, + }); + } + if (promptFiber === undefined) { return { threadId: input.threadId, @@ -1798,6 +2117,117 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv const stopAll: OmpAdapterShape["stopAll"] = () => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + const forkSession: OmpAdapterShape["forkSession"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const sessionId = parseOmpResume(ctx.session.resumeCursor)?.sessionId; + const cwd = ctx.session.cwd; + // `cwd` is required: omitting it fails inside omp with a bare + // "path must be of type string" internal error (verified on 18.1.x). + if (sessionId === undefined || cwd === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/fork", + detail: "The Oh My Pi session has no native session id and cwd to fork.", + }); + } + const response = yield* ctx.acp + .request("session/fork", { sessionId, cwd }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/fork", error), + ), + ); + const forkedSessionId = parseOmpForkedSessionId(response); + if (forkedSessionId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/fork", + detail: "Oh My Pi returned no session id for the forked session.", + }); + } + return { + sessionId: forkedSessionId, + cwd, + resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId: forkedSessionId }, + }; + }); + + /** + * Runs one `session/list` request on a dedicated, scoped `omp acp` + * child that is only initialized — no session is created, so discovery + * never shows up in the very list it is reading. + * + * A live session's connection is deliberately NOT reused: which + * sessions exist is a property of the configured binary and profile, + * not of whichever thread happens to be open, and an open session may + * have been spawned from an older settings snapshot. + */ + const requestSessionList = (input: { + readonly cwd: string; + readonly payload: Record; + }) => + Effect.gen(function* () { + const effectiveOmpSettings = options?.resolveSettings + ? yield* options.resolveSettings + : ompSettings; + return yield* Effect.scoped( + Effect.gen(function* () { + const acp = yield* makeOmpAcpRuntime({ + ompSettings: effectiveOmpSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd: input.cwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + // Nothing answers elicitation on a discovery connection. + enableElicitation: false, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + yield* acp.initialize(); + return yield* acp.request("session/list", input.payload); + }), + ); + }); + + const listNativeSessions: OmpAdapterShape["listNativeSessions"] = (input) => + Effect.gen(function* () { + const cwd = input.cwd.trim(); + if (!cwd) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "listNativeSessions", + issue: "cwd is required and must be non-empty.", + }); + } + const filterCwd = input.filterCwd?.trim(); + const cursor = input.cursor?.trim(); + const response = yield* requestSessionList({ + cwd: path.resolve(cwd), + payload: { + ...(filterCwd ? { cwd: path.resolve(filterCwd) } : {}), + ...(cursor ? { cursor } : {}), + }, + }).pipe( + Effect.mapError( + (error) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/list", + detail: error.message, + cause: error, + }), + ), + ); + const listing = parseOmpSessionList(response); + return { + sessions: listing.sessions.map((session) => ({ + ...session, + resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId: session.sessionId }, + })), + ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}), + skippedCount: listing.skippedCount, + }; + }); + yield* Effect.addFinalizer(() => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( Effect.catch((cause) => @@ -1831,6 +2261,8 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv respondToUserInput, stopSession, listSessions, + forkSession, + listNativeSessions, hasSession, stopAll, streamEvents, diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts index 75a9303b198f..3c90678724b4 100644 --- a/apps/server/src/provider/Layers/OmpProvider.test.ts +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -52,6 +52,14 @@ function selectDescriptor( }; } +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( extraEnv?: Record, ) { @@ -322,6 +330,14 @@ describe("getOmpFallbackModels", () => { }).map((model) => model.slug), ).toEqual(["internal/omp-model"]); }); + + it("reports unknown capabilities for custom models the probe never validated", () => { + expect( + getOmpFallbackModels({ + customModels: ["internal/omp-model"], + }).map((model) => model.capabilities), + ).toEqual([null]); + }); }); describe("buildOmpProviderSnapshot", () => { @@ -396,6 +412,62 @@ describe("buildOmpProviderSnapshot", () => { expect(snapshot.auth).toEqual({ status: "unknown" }); expect(snapshot.usageLimits).toBeUndefined(); }); + + it("names a custom model omp does not advertise instead of failing silently", () => { + const snapshot = buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: { ...baseOmpSettings, customModels: ["ghost/model"] }, + version: "18.0.6", + discoveredModels: [ + { + slug: "anthropic/claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: null, + }, + ], + }); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain('"ghost/model"'); + }); + + it("stays quiet when every custom model is advertised", () => { + const snapshot = buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: { ...baseOmpSettings, customModels: ["anthropic/claude-opus-4-6"] }, + version: "18.0.6", + message: "1 upstream provider configured through Oh My Pi.", + discoveredModels: [ + { + slug: "anthropic/claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: null, + }, + ], + }); + expect(snapshot.status).toBe("ready"); + expect(snapshot.message).toBe("1 upstream provider configured through Oh My Pi."); + }); + + it("cannot judge custom models without a discovered catalog", () => { + const snapshot = buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: { ...baseOmpSettings, customModels: ["ghost/model"] }, + version: "18.0.6", + }); + expect(snapshot.status).toBe("ready"); + expect(snapshot.message).toBeUndefined(); + }); + + it("reports unknown capabilities for custom models the probe never validated", () => { + const snapshot = buildOmpProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + ompSettings: { ...baseOmpSettings, customModels: ["internal/omp-model"] }, + version: "18.0.6", + }); + expect(snapshot.models.map((model) => model.capabilities)).toEqual([null]); + }); }); describe("buildOmpCapabilitiesFromConfigOptions", () => { @@ -450,6 +522,138 @@ describe("buildOmpCapabilitiesFromConfigOptions", () => { createModelCapabilities({ optionDescriptors: [] }), ); }); + + it("mirrors the full omp ladder including minimal and xhigh", () => { + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "select", + currentValue: "xhigh", + options: [ + { name: "Off", value: "off" }, + { name: "Minimal", value: "minimal" }, + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Extra High", value: "xhigh" }, + { name: "Max", value: "max" }, + { name: "Auto", value: "auto" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Thinking", [ + { id: "off", label: "Off" }, + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "auto", label: "Auto" }, + ]), + ], + }), + ); + }); + + it("dedupes aliased thinking values to one picker option", () => { + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "select", + currentValue: "off", + options: [ + { name: "None", value: "none" }, + { name: "Off", value: "off" }, + { name: "Extra High", value: "extra-high" }, + { name: "XHigh", value: "xhigh" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Thinking", [ + { id: "off", label: "None", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ]), + ], + }), + ); + }); + + it("maps the context_size select onto a contextWindow descriptor", () => { + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "select", + currentValue: "1m", + options: [ + { name: "272K", value: "272k" }, + { name: "1M", value: "1m" }, + ], + category: "model_config", + id: "context_size", + name: "Context", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("contextWindow", "Context", [ + { id: "272k", label: "272K" }, + { id: "1m", label: "1M", isDefault: true }, + ]), + ], + }), + ); + }); + + it("maps the boolean fast toggle onto a fastMode descriptor", () => { + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "boolean", + currentValue: true, + category: "model_config", + id: "fast", + name: "Fast", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), + ); + expect( + buildOmpCapabilitiesFromConfigOptions([ + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + }, + ]), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), + ); + }); }); describe("checkOmpProviderStatus", () => { @@ -574,6 +778,40 @@ describe("discoverOmpModelsViaAcp", () => { }), ); + effectIt.live("marks only the probe-validated model capable", () => + Effect.gen(function* () { + const wrapperPath = yield* node(makeMockAgentWrapper()); + + const models = yield* node( + discoverOmpModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }).pipe(Effect.scoped), + ); + + // The mock probe session sits on zhipu-coding-plan/glm-5.3, so only that + // entry may carry the probed reasoning options; the rest stay unknown. + const bySlug = new Map(models.map((model) => [model.slug, model])); + expect(bySlug.get("anthropic/claude-opus-4-6")?.capabilities).toBeNull(); + expect(bySlug.get("openai/gpt-5.4")?.capabilities).toBeNull(); + const probed = bySlug.get("zhipu-coding-plan/glm-5.3")?.capabilities; + expect(probed).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" }, + ]), + ], + }), + ); + }), + ); + // Stopping the probe kills the agent with SIGTERM; Windows terminates the // process instead, so the mock never sees a signal to log. effectIt.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( @@ -635,6 +873,34 @@ describe("resolveOmpAcpConfigUpdates", () => { ).toEqual([{ configId: "thinking", value: "auto" }]); }); + it("writes minimal and xhigh back to their advertised raw values", () => { + const ladder = [ + { + type: "select", + currentValue: "off", + options: [ + { name: "Off", value: "off" }, + { name: "Minimal", value: "minimal" }, + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Extra High", value: "extra-high" }, + { name: "Max", value: "max" }, + { name: "Auto", value: "auto" }, + ], + category: "thought_level", + id: "thinking", + name: "Thinking", + }, + ] satisfies ReadonlyArray; + expect(resolveOmpAcpConfigUpdates(ladder, [{ id: "reasoning", value: "minimal" }])).toEqual([ + { configId: "thinking", value: "minimal" }, + ]); + expect(resolveOmpAcpConfigUpdates(ladder, [{ id: "reasoning", value: "xhigh" }])).toEqual([ + { configId: "thinking", value: "extra-high" }, + ]); + }); + it("ignores unknown reasoning values and empty selections", () => { expect( resolveOmpAcpConfigUpdates(ompConfigOptions, [{ id: "reasoning", value: "ludicrous" }]), diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts index 2361d0dd243a..44dff58c0481 100644 --- a/apps/server/src/provider/Layers/OmpProvider.ts +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -23,6 +23,7 @@ import { createModelCapabilities, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, + readCustomModelEntries, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -138,9 +139,13 @@ function normalizeOmpReasoningValue(value: string | null | undefined): string | case "off": case "none": return "off"; - // omp's thinking select legitimately offers {off, auto} on auto models. + // omp's own ladder is off|minimal|low|medium|high|xhigh|max|auto; the ACP + // thought_level select advertises a model-dependent subset (e.g. only + // {off, auto} on auto models). Aliases normalize so the picker round-trips + // through resolveOmpAcpConfigUpdates back to the raw advertised value. case "auto": return "auto"; + case "minimal": case "low": case "medium": case "high": @@ -243,15 +248,18 @@ export function buildOmpCapabilitiesFromConfigOptions( if (!configOptions || configOptions.length === 0) { return EMPTY_CAPABILITIES; } - const reasoningConfig = findOmpEffortConfigOption(configOptions); + // Aliased raw values (none/off, extra-high/xhigh) normalize to one picker + // id; first wins so the descriptor never offers duplicate ids for one slot. + const seenReasoningValues = new Set(); const reasoningEffortLevels = reasoningConfig?.type === "select" ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { const normalizedValue = normalizeOmpReasoningValue(entry.value); - if (!normalizedValue) { + if (!normalizedValue || seenReasoningValues.has(normalizedValue)) { return []; } + seenReasoningValues.add(normalizedValue); return [ { value: normalizedValue, @@ -379,12 +387,12 @@ function buildOmpDiscoveredModelsFromConfigOptions( 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. + // Capability-truthfulness rule: omp re-validates dependent options per model + // and ACP offers no per-model probe, so only the probe session's current + // model carries capabilities (exactly the option set its session advertised). + // Every other entry reports null (unknown) so the UI never offers reasoning + // levels omp would reject; the adapter re-reads options per model at + // selection time. No ACP session is ever spawned per model. const currentModelId = modelOption.type === "select" ? modelOption.currentValue?.trim() : undefined; const probedCapabilities = buildOmpCapabilitiesFromConfigOptions(configOptions); @@ -455,10 +463,70 @@ export const discoverOmpModelsViaAcp = ( Effect.scoped, ); +/** + * Bare custom slugs were never probe-validated, so stamping the driver's + * empty default descriptor set on them would falsely assert "no options". + * Report unknown (null) instead; entries that declare their own capabilities + * keep them. + */ +function withUnknownBareCustomCapabilities( + models: ReadonlyArray, + customModels: Pick["customModels"], +): ReadonlyArray { + const declaredBySlug = new Map( + readCustomModelEntries(customModels).map((entry) => [entry.slug, entry.capabilities] as const), + ); + return models.map((model) => { + if (!model.isCustom || declaredBySlug.get(model.slug) !== null) { + return model; + } + return { ...model, capabilities: null }; + }); +} + +/** + * Mirrors the adapter's write guard: `applyOmpAcpModelSelection` skips the + * model write when the requested base id is absent from the live catalog and + * the turn is answered by the session's kept model instead. The snapshot can + * predict that divergence for configured custom models, so it names them + * rather than leaving the user silently answered by a different model. With + * no discovered catalog there is nothing to check against, so no warning. + */ +function buildUnadvertisedCustomModelMessage( + discoveredModels: ReadonlyArray | undefined, + customModels: Pick["customModels"], +): string | undefined { + if (!discoveredModels || discoveredModels.length === 0) { + return undefined; + } + const advertised = new Set(discoveredModels.map((model) => model.slug)); + const unadvertised: Array = []; + for (const entry of readCustomModelEntries(customModels)) { + // Same base-id comparison as the adapter: bracket traits + // (`model[fast=true]`) are stripped before the catalog lookup. + const base = entry.slug.includes("[") + ? entry.slug.slice(0, entry.slug.indexOf("[")).trim() + : entry.slug; + if (base.length > 0 && !advertised.has(base) && !unadvertised.includes(entry.slug)) { + unadvertised.push(entry.slug); + } + } + if (unadvertised.length === 0) { + return undefined; + } + const names = unadvertised.map((slug) => `"${slug}"`).join(", "); + return unadvertised.length === 1 + ? `Custom model ${names} is not advertised by omp; turns that request it will be answered by omp's configured model instead.` + : `Custom models ${names} are not advertised by omp; turns that request them will be answered by omp's configured model instead.`; +} + export function getOmpFallbackModels( ompSettings: Pick, ): ReadonlyArray { - return providerModelsFromSettings([], ompSettings.customModels, EMPTY_CAPABILITIES); + return withUnknownBareCustomCapabilities( + providerModelsFromSettings([], ompSettings.customModels, EMPTY_CAPABILITIES), + ompSettings.customModels, + ); } function normalizeOmpConfigOptionToken(value: string | null | undefined): string { @@ -587,20 +655,28 @@ export function buildOmpProviderSnapshot(input: { readonly usageLimits?: ServerProviderUsageLimits; }): ServerProviderDraft { const status = input.status ?? "ready"; - const message = joinProviderMessages(input.message, input.discoveryWarning); + const unadvertisedModelMessage = buildUnadvertisedCustomModelMessage( + input.discoveredModels, + input.ompSettings.customModels, + ); + const combinedWarning = joinProviderMessages(input.discoveryWarning, unadvertisedModelMessage); + const message = joinProviderMessages(input.message, combinedWarning); return buildServerProvider({ presentation: OMP_PRESENTATION, enabled: input.ompSettings.enabled, checkedAt: input.checkedAt, - models: providerModelsFromSettings( - input.discoveredModels ?? [], + models: withUnknownBareCustomCapabilities( + providerModelsFromSettings( + input.discoveredModels ?? [], + input.ompSettings.customModels, + EMPTY_CAPABILITIES, + ), input.ompSettings.customModels, - EMPTY_CAPABILITIES, ), probe: { installed: true, version: input.version, - status: input.discoveryWarning && status === "ready" ? "warning" : status, + status: combinedWarning && status === "ready" ? "warning" : status, auth: input.auth ?? { status: "unknown" }, ...(message ? { message } : {}), ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), diff --git a/apps/server/src/provider/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts index 4c339dceca61..df3ca01523bf 100644 --- a/apps/server/src/provider/Services/OmpAdapter.ts +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -9,11 +9,82 @@ * * @module OmpAdapter */ +import type { ThreadId } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +import type { OmpNativeSessionInfo } from "../acp/OmpAcpSupport.ts"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; +/** + * Cursor persisted on a `ProviderSession` so a later `startSession` reopens + * the same omp session through `session/load`. Versioned because the + * adapter refuses cursors it cannot read rather than resuming the wrong + * conversation. + */ +export interface OmpResumeCursor { + readonly schemaVersion: number; + readonly sessionId: string; +} + +/** An omp session discovered through `session/list`, ready to resume. */ +export interface OmpDiscoveredSession extends OmpNativeSessionInfo { + readonly resumeCursor: OmpResumeCursor; +} + +export interface OmpDiscoveredSessionPage { + readonly sessions: ReadonlyArray; + readonly nextCursor?: string; + /** Entries omp returned that carried no usable session id or cwd. */ + readonly skippedCount: number; +} + +export interface OmpListNativeSessionsInput { + /** + * Directory the discovery connection runs in. omp's session store is + * global, so this only decides where the short-lived `omp acp` child is + * spawned, not which sessions come back. + */ + readonly cwd: string; + /** Restricts results to one workspace. Omitted lists every omp session. */ + readonly filterCwd?: string; + /** Opaque cursor from a previous page's `nextCursor`. */ + readonly cursor?: string; +} + +/** A live omp session copied at its current state by `session/fork`. */ +export interface OmpForkedSession { + readonly sessionId: string; + readonly cwd: string; + readonly resumeCursor: OmpResumeCursor; +} + /** * OmpAdapterShape — per-instance Oh My Pi adapter contract. Carries * a branded driver kind as the nominal discriminant. + * + * Extends the shared adapter contract with the two session-store + * operations omp exposes over ACP and the other providers do not: + * forking a live session and enumerating the agent's own sessions. */ -export interface OmpAdapterShape extends ProviderAdapterShape {} +export interface OmpAdapterShape extends ProviderAdapterShape { + /** + * Copies a live session at its current state through ACP `session/fork` + * and returns a cursor the caller can start a sibling thread from. The + * original session keeps running and is not rewound — ACP has no rewind + * (see `capabilities.supportsConversationRollback`). + */ + readonly forkSession: ( + threadId: ThreadId, + ) => Effect.Effect; + + /** + * Enumerates the sessions omp itself persisted — including the ones a + * user started in the terminal — so they can be resumed here. Served by + * a live session's connection when one exists, otherwise by a + * short-lived `omp acp` child that only initializes. + */ + readonly listNativeSessions: ( + input: OmpListNativeSessionsInput, + ) => Effect.Effect; +} diff --git a/apps/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts index 86ee94ab78e9..c80ab9b90e4a 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.test.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -7,6 +7,8 @@ import { applyOmpAcpModelSelection, buildOmpAcpSpawnInput, ompAcpSpawnArgs, + parseOmpForkedSessionId, + parseOmpSessionList, resolveOmpAcpBaseModelId, } from "./OmpAcpSupport.ts"; @@ -309,3 +311,81 @@ describe("applyOmpAcpModelSelection", () => { }), ); }); + +describe("parseOmpSessionList", () => { + it("keeps cwd, title, updatedAt and omp's transcript stats", () => { + const listing = parseOmpSessionList({ + sessions: [ + { + sessionId: "omp-1", + cwd: "C:/work/repo", + title: "Terminal session", + updatedAt: "2026-02-03T04:05:06.000Z", + _meta: { messageCount: 12, size: 8192 }, + }, + ], + nextCursor: "cursor-2", + }); + + expect(listing).toEqual({ + sessions: [ + { + sessionId: "omp-1", + cwd: "C:/work/repo", + title: "Terminal session", + updatedAt: "2026-02-03T04:05:06.000Z", + messageCount: 12, + sizeBytes: 8192, + }, + ], + nextCursor: "cursor-2", + skippedCount: 0, + }); + }); + + it("skips unusable entries instead of failing the whole page", () => { + const listing = parseOmpSessionList({ + sessions: [ + { sessionId: "omp-ok", cwd: "/work" }, + { sessionId: 42, cwd: "/work" }, + { sessionId: "omp-no-cwd" }, + { cwd: "/work" }, + null, + "nonsense", + ], + }); + + expect(listing.sessions).toEqual([{ sessionId: "omp-ok", cwd: "/work" }]); + expect(listing.skippedCount).toBe(5); + expect(listing.nextCursor).toBeUndefined(); + }); + + it("reads a non-numeric transcript stat as absent rather than as a count", () => { + const listing = parseOmpSessionList({ + sessions: [{ sessionId: "omp-1", cwd: "/work", _meta: { messageCount: "many", size: -1 } }], + }); + + expect(listing.sessions[0]).toEqual({ sessionId: "omp-1", cwd: "/work" }); + }); + + it("returns an empty page for a response without a sessions array", () => { + expect(parseOmpSessionList({ sessions: "none" })).toEqual({ + sessions: [], + skippedCount: 0, + }); + expect(parseOmpSessionList(undefined)).toEqual({ sessions: [], skippedCount: 0 }); + }); +}); + +describe("parseOmpForkedSessionId", () => { + it("reads the forked session id out of a full session setup response", () => { + expect( + parseOmpForkedSessionId({ sessionId: "omp-1-fork-1", configOptions: [], modes: null }), + ).toBe("omp-1-fork-1"); + }); + + it("reports a response without a usable id", () => { + expect(parseOmpForkedSessionId({ configOptions: [] })).toBeUndefined(); + expect(parseOmpForkedSessionId({ sessionId: " " })).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index 2dce9cf53a00..c2bde300e0c2 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -205,3 +205,92 @@ export function resolveOmpAcpBaseModelId(model: string | null | undefined): stri const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")).trim() : trimmed; return base.length > 0 ? base : undefined; } + +/** + * One omp session as reported by ACP `session/list`. omp answers with the + * spec's `SessionInfo` plus its own transcript stats under `_meta` + * (`messageCount`, `size`), which the picker shows so a migrating terminal + * user can tell an empty session from a long one. + */ +export interface OmpNativeSessionInfo { + readonly sessionId: string; + readonly cwd: string; + readonly title?: string; + readonly updatedAt?: string; + readonly messageCount?: number; + readonly sizeBytes?: number; +} + +export interface OmpNativeSessionListing { + readonly sessions: ReadonlyArray; + readonly nextCursor?: string; + /** Entries omp returned without a usable session id or cwd. */ + readonly skippedCount: number; +} + +function listRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function listTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** + * Parses a `session/list` response leniently. A session picker must not go + * blank because one persisted session lost its cwd or carries a field shape + * this build does not know: unusable entries are counted and dropped, every + * readable one is kept. + */ +export function parseOmpSessionList(raw: unknown): OmpNativeSessionListing { + const response = listRecord(raw); + const entries = Array.isArray(response?.sessions) ? response.sessions : []; + const sessions: Array = []; + let skippedCount = 0; + for (const entry of entries) { + const record = listRecord(entry); + const sessionId = listTrimmedString(record?.sessionId); + const cwd = listTrimmedString(record?.cwd); + if (!record || !sessionId || !cwd) { + skippedCount += 1; + continue; + } + const meta = listRecord(record._meta); + const title = listTrimmedString(record.title); + const updatedAt = listTrimmedString(record.updatedAt); + // omp reports transcript stats under `_meta`; anything non-numeric there + // is another agent's metadata and must not reach the picker as a count. + const messageCount = meta?.messageCount; + const sizeBytes = meta?.size; + sessions.push({ + sessionId, + cwd, + ...(title ? { title } : {}), + ...(updatedAt ? { updatedAt } : {}), + ...(typeof messageCount === "number" && Number.isInteger(messageCount) && messageCount >= 0 + ? { messageCount } + : {}), + ...(typeof sizeBytes === "number" && Number.isInteger(sizeBytes) && sizeBytes >= 0 + ? { sizeBytes } + : {}), + }); + } + const nextCursor = listTrimmedString(response?.nextCursor); + return { + sessions, + ...(nextCursor ? { nextCursor } : {}), + skippedCount, + }; +} + +/** + * Reads the new session id out of a `session/fork` response. omp answers + * with a full session setup payload (`configOptions`, `modes`, `models`); + * only the id is load-bearing for the client, the rest is re-read when the + * fork is opened as its own session. + */ +export function parseOmpForkedSessionId(raw: unknown): string | undefined { + return listTrimmedString(listRecord(raw)?.sessionId); +} diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 9d46174d174f..9f97b93b057a 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -28,6 +28,7 @@ import { type ComposerCitationCommentRequest, } from "./ComposerCitationNode"; import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; +import { isOpenableSkillPath } from "./ComposerPromptEditor"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); @@ -862,3 +863,20 @@ describe("citation comment opening", () => { }); }); }); + +describe("isOpenableSkillPath", () => { + it("opens filesystem paths, including Windows drives", () => { + expect(isOpenableSkillPath("/Users/matt/.codex/skills/review/SKILL.md")).toBe(true); + expect(isOpenableSkillPath("C:/Storage/.omp/skills/tdd/SKILL.md")).toBe(true); + expect(isOpenableSkillPath("C:\\Storage\\.omp\\skills\\tdd\\SKILL.md")).toBe(true); + expect(isOpenableSkillPath(".omp/skills/tdd/SKILL.md")).toBe(true); + }); + + it("refuses internal URLs and blank paths", () => { + expect( + isOpenableSkillPath("C:\Storage\AI\Agents\.omp\curated-skills\matt\matt-tdd\SKILL.md"), + ).toBe(false); + expect(isOpenableSkillPath("https://example.com/SKILL.md")).toBe(false); + expect(isOpenableSkillPath(" ")).toBe(false); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 3ba086e0e981..857193477421 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -250,6 +250,19 @@ function resolveSkillDescription( return description || null; } +/** + * A skill path is only openable when it names a file the right panel can read. + * Oh My Pi resolves a skill by name through several roots and never reports the + * file it came from, so its skills carry an internal `skill://` URL; offering + * "View instructions" for one would open a path that does not exist. + */ +export function isOpenableSkillPath(path: string): boolean { + const trimmed = path.trim(); + if (trimmed.length === 0) return false; + const hasWindowsDrive = /^[A-Za-z]:[\\/]/.test(trimmed); + return hasWindowsDrive || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed); +} + type ComposerSkillMetadata = { label: string; description: string | null; @@ -300,7 +313,7 @@ function ComposerSkillDecorator(props: { props.skillDescription ?? "No description is available for this skill."}

- {skill?.path ? ( + {skill?.path && isOpenableSkillPath(skill.path) ? ( diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..71157823cd6f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -105,5 +105,43 @@ file-bearing messages, and an image-only server can fail the entire environment' replaying one such event. Rollouts and downgrades must account for persisted history as well as current client support. +## Oh My Pi speaks two protocols, and only one carries sessions + +omp runs sessions over ACP (`omp acp`), but its menu catalogs live in RPC mode. `session/new` +returns only `sessionId`, `configOptions` and `modes`, so skills and slash commands are read from +a separate `omp --mode rpc --no-session --no-lsp` probe whose startup `available_commands_update` +frame lists every command, including one `skill:` per discovered skill. The probe closes +stdin immediately — RPC mode exits when stdin ends — so it never calls a model. Re-implementing +omp's own skill resolution in T3 was rejected: it layers native, plugin, Claude, Codex, agents, +opencode and github providers with per-source toggles and ignore globs, and would drift on every +omp release. See the [probe](../../apps/server/src/provider/Drivers/OmpCommands.ts). + +The RPC catalog reports no filesystem path for a skill, so omp skills carry an internal +`skill:///SKILL.md` path. Composers must not offer "view instructions" for a path that is +not openable; the check lives with the chip in +[ComposerPromptEditor](../../apps/web/src/components/ComposerPromptEditor.tsx). + +The composer inserts `$name` for every provider. omp has no `$` syntax: it exposes each skill as +`/skill:` and recognizes that token inside prose, so known mentions are rewritten in place +with no last-block or one-command-per-message rule to respect. See +[the dispatcher](../../apps/server/src/provider/Drivers/OmpSkillDispatch.ts). + +Rewind stays out of reach. omp's RPC mode can branch a session from an entry id, but ACP exposes +only `session/list`, `session/fork`, `session/load` and `session/close`, so the adapter keeps +`supportsConversationRollback: false` and a fork is a copy of the live session, not a rewind. +`session/fork` also needs `cwd` alongside `sessionId`; omitting it fails with an internal error +about a missing `path`. + +Token and cost reporting arrive as an unstable ACP extension: omp sends `usage_update` +(`size` = context window, `used` = tokens, plus `cost`) as a session update and repeats turn +totals on the prompt response. `usedTokens` tracks context occupancy, so per-turn totals must not +overwrite it or the meter shrinks every turn. + +omp ships outside npm and Homebrew and updates itself: `omp update --check` reports the current +and latest version and `omp update` installs, which is the only advisory source T3 trusts for it. +Subscription limits come from `omp usage --json`, which enumerates authenticated accounts per +provider and doubles as the auth probe — a failed probe must stay `unknown` rather than report +`unauthenticated`. + Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference handling is documented under [citations](./assistant-citations.md). From 25b68b4ebfd39b92ed7724847ba4886987fbaacf Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 12:16:54 -0300 Subject: [PATCH 08/22] feat(omp): import terminal sessions, honest provider labels, user docs --- .../src/project/AgentSessionImporter.test.ts | 96 ++- .../src/project/AgentSessionImporter.ts | 37 +- .../src/project/AgentSessionScanner.test.ts | 559 +++++++++++++++--- .../server/src/project/AgentSessionScanner.ts | 178 +++++- apps/web/src/components/CommandPalette.tsx | 22 +- apps/web/src/components/Sidebar.tsx | 19 +- .../chat/ComposerCommandMenu.test.tsx | 44 ++ .../components/chat/ComposerCommandMenu.tsx | 14 + .../components/onboarding/WelcomeWizard.tsx | 9 +- apps/web/src/providerModels.test.ts | 49 +- apps/web/src/providerModels.ts | 54 +- docs/user/install.md | 4 +- docs/user/providers-oh-my-pi.md | 53 ++ packages/contracts/src/agentSessions.ts | 2 +- 14 files changed, 1013 insertions(+), 127 deletions(-) create mode 100644 docs/user/providers-oh-my-pi.md diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4eb03a5cc036..15f78b75e610 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -64,12 +64,16 @@ import * as AgentSessionScanner from "./AgentSessionScanner.ts"; const PROJECT_ID = ProjectId.make("project-1"); const WORKSPACE_ROOT = "/tmp/project-from-server"; const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const OMP_SESSION_ID = "01a06f8e-040e-729f-b231-1fd3c4abb63c"; const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ +const makeThread = ( + source: "codex" | "claudeAgent" | "omp", +): AgentSessionScanner.AgentSessionThread => ({ source, providerInstanceId: ProviderInstanceId.make(source), - providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + providerSessionId: + source === "codex" ? "codex-session" : source === "omp" ? OMP_SESSION_ID : CLAUDE_SESSION_ID, title: `Imported ${source} thread`, model: null, createdAt: "2026-08-24T10:00:00.000Z", @@ -538,6 +542,94 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { expect(commands).toHaveLength(0); }), ); + + it.effect("binds an imported omp thread to the cursor the omp adapter parses", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(makeThread("omp"))), + }); + const commands: Array = []; + const bindings: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(bindings).toMatchObject([ + { + provider: "omp", + providerInstanceId: "omp", + // `parseOmpResume` in OmpAdapter.ts accepts exactly this shape. + resumeCursor: { schemaVersion: 1, sessionId: OMP_SESSION_ID }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + ]); + }), + ); + + it.effect("skips an omp thread whose session id cannot resume", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed(makeThreadOutcome({ ...makeThread("omp"), providerSessionId: " " })), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind an unresumable omp session"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(commands).toHaveLength(0); + }), + ); }); }); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 5ebb41a1bb54..3c163ec0c016 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -32,6 +32,13 @@ import * as AgentSessionScanner from "./AgentSessionScanner.ts"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +/** + * Mirrors `OMP_RESUME_VERSION` in provider/Layers/OmpAdapter.ts: its + * `parseOmpResume` rejects a cursor carrying any other schema version, and an + * imported thread whose cursor it rejects can never resume. + */ +const OMP_RESUME_SCHEMA_VERSION = 1; + class AgentSessionUnresumableSessionError extends Schema.TaggedError()( "AgentSessionUnresumableSessionError", { @@ -96,6 +103,22 @@ function hasImportBlockingActivity( ); } +/** + * Each adapter parses its own cursor: Codex keys resume by its rollout + * conversation id, omp by an ACP session id behind a schema version, and + * Claude replays the transcript named by `resume` into a fresh thread id. + */ +function resolveImportedResumeCursor( + thread: AgentSessionScanner.AgentSessionThread, + threadId: ThreadId, +): Record { + if (thread.source === "codex") return { threadId: thread.providerSessionId }; + if (thread.source === "omp") { + return { schemaVersion: OMP_RESUME_SCHEMA_VERSION, sessionId: thread.providerSessionId }; + } + return { threadId, resume: thread.providerSessionId }; +} + /** Import recent transcript text and persist the cursor needed to resume its provider session. */ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( input: AgentSessionImportInput, @@ -183,6 +206,15 @@ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(fu }); } + // omp resumes through ACP `session/load` keyed by the transcript's + // own session id; a blank one leaves the thread unresumable. + if (thread.source === "omp" && thread.providerSessionId.trim().length === 0) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { return yield* new AgentSessionThreadProjectConflictError({ threadId, @@ -230,10 +262,7 @@ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(fu providerInstanceId: thread.providerInstanceId, status: "stopped", runtimeMode: DEFAULT_RUNTIME_MODE, - resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, + resumeCursor: resolveImportedResumeCursor(thread, threadId), runtimePayload: { cwd: workspaceRoot }, }, { onConflict: "ignore" }, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 41c6c8a961c0..8e9a79f024a5 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -9,6 +9,7 @@ import { type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -76,6 +77,12 @@ interface ScannerTestInput { /** Base dir for the test ServerConfig; worktreesDir derives from it. */ readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + /** + * Pins the inherited environment. omp resolves its agent directory from + * environment variables alone, so a developer's own `OMP_PROFILE` or + * `PI_CODING_AGENT_DIR` would otherwise leak into those assertions. + */ + readonly hostEnvironment?: NodeJS.ProcessEnv; } const makeScannerTestLayer = (input: ScannerTestInput) => @@ -96,6 +103,9 @@ const makeScannerTestLayer = (input: ScannerTestInput) => input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ...(input.hostEnvironment === undefined + ? [] + : [Layer.succeed(HostProcessEnvironment, input.hostEnvironment)]), ), ), ); @@ -177,6 +187,38 @@ function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { : records; } +/** + * An enabled omp instance whose agent directory is pinned through the + * environment. omp has no home setting: `PI_CODING_AGENT_DIR` is what the + * spawned CLI would resolve its sessions from. + */ +const ompProviderInstances = ( + environment: ReadonlyArray<{ readonly name: string; readonly value: string }>, +): ContractServerSettings["providerInstances"] => ({ + [ProviderInstanceId.make("omp")]: { + driver: ProviderDriverKind.make("omp"), + config: { enabled: true }, + environment: environment.map((variable) => ({ ...variable, sensitive: false })), + }, +}); + +/** + * omp records the session's real cwd in its `session` record; the directory + * name is a lossy mangling of the same path. On Windows the recorded spelling + * uses backslashes, which is what a migrating terminal user actually has. + */ +const ompSessionCwd = (cwd: string) => + NodeOS.platform() === "win32" ? cwd.replaceAll("/", "\\") : cwd; + +const ompSessionLine = (input: { readonly cwd: string; readonly sessionId: string }) => + `${encodeTranscriptRecord({ + type: "session", + version: 3, + id: input.sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + cwd: ompSessionCwd(input.cwd), + })}\n`; + it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => @@ -400,68 +442,72 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("returns the imported project ID through a realpath alias", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const workspaceAlias = path.join(linkParent, "workspace-alias"); - yield* fileSystem.symlink(workspace, workspaceAlias); + it.effect.skipIf(!symlinksSupported)( + "returns the imported project ID through a realpath alias", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(workspaceAlias), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - importedWorkspaceRoots: [workspace], - }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); - expect(result.candidates[0]).toMatchObject({ - path: workspace, - projectId: ProjectId.make("project-1"), - alreadyImported: true, - git: null, - }); - }), + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), ); - it.effect("matches a persisted project alias to a transcript realpath", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const workspaceAlias = path.join(linkParent, "workspace-alias"); - yield* fileSystem.symlink(workspace, workspaceAlias); + it.effect.skipIf(!symlinksSupported)( + "matches a persisted project alias to a transcript realpath", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - importedWorkspaceRoots: [workspaceAlias], - }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); - expect(result.candidates[0]).toMatchObject({ - path: workspaceAlias, - projectId: ProjectId.make("project-1"), - alreadyImported: true, - git: null, - }); - }), + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), ); it.effect("merges case aliases and preserves the persisted project path", () => @@ -998,31 +1044,33 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const fileSystem = yield* FileSystem.FileSystem; + it.effect.skipIf(!symlinksSupported)( + "excludes sandboxes reached through a symlink into the worktrees dir", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; - // The recorded cwd is a symlink whose own spelling looks harmless; - // only its realpath reveals the managed sandbox. - const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); - yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); - const symlinkCwd = path.join(linkParent, "innocent-project"); - yield* fileSystem.symlink(worktreeCwd, symlinkCwd); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(symlinkCwd), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); - const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); - expect(result.candidates).toEqual([]); - }), + expect(result.candidates).toEqual([]); + }), ); it.effect("finds the cwd on a later line when the first records carry none", () => @@ -1369,6 +1417,126 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }), ); + + it.effect("groups omp sessions by the cwd their session record names", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const ompAgentDir = yield* makeTempDir("t3code-omp-agent-"); + const workspace = yield* makeTempDir("t3code-omp-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-omp-workspace-other-"); + + // Both transcripts sit in the same mangled directory while naming + // different cwds: the directory name must not decide the grouping. + yield* writeTranscript({ + filePath: path.join( + ompAgentDir, + "sessions", + "--mangled--", + "2026-08-23T12-00-00-000Z_session-a.jsonl", + ), + contents: ompSessionLine({ cwd: workspace, sessionId: "session-a" }), + mtimeMs: Date.parse("2026-08-23T12:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + ompAgentDir, + "sessions", + "--mangled--", + "2026-08-24T12-00-00-000Z_session-b.jsonl", + ), + contents: ompSessionLine({ cwd: otherWorkspace, sessionId: "session-b" }), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + hostEnvironment: {}, + providerInstances: ompProviderInstances([ + { name: "PI_CODING_AGENT_DIR", value: ompAgentDir }, + ]), + }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["omp"], + threadCount: 1, + lastActiveAt: "2026-08-24T12:00:00.000Z", + alreadyImported: false, + git: null, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["omp"], + threadCount: 1, + lastActiveAt: "2026-08-23T12:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect("ignores omp sessions when no omp instance is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const ompAgentDir = yield* makeTempDir("t3code-omp-agent-"); + const workspace = yield* makeTempDir("t3code-omp-workspace-"); + + yield* writeTranscript({ + filePath: path.join(ompAgentDir, "sessions", "--mangled--", "s.jsonl"), + contents: ompSessionLine({ cwd: workspace, sessionId: "session-a" }), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + // omp ships disabled, so the default settings must not surface it even + // with the agent directory present in the environment. + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + hostEnvironment: { PI_CODING_AGENT_DIR: ompAgentDir }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("resolves the omp agent directory from a profile instead of the override", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const ompAgentDir = yield* makeTempDir("t3code-omp-agent-"); + const workspace = yield* makeTempDir("t3code-omp-workspace-"); + + yield* writeTranscript({ + filePath: path.join(ompAgentDir, "sessions", "--mangled--", "s.jsonl"), + contents: ompSessionLine({ cwd: workspace, sessionId: "session-a" }), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + // omp roots a profile's agent directory under its config home and + // ignores PI_CODING_AGENT_DIR, so these sessions belong to no profile. + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + hostEnvironment: {}, + providerInstances: ompProviderInstances([ + { name: "PI_CODING_AGENT_DIR", value: ompAgentDir }, + { name: "OMP_PROFILE", value: "work" }, + ]), + }); + + expect(result.candidates).toEqual([]); + }), + ); }); describe("recentThreads", () => { @@ -2605,6 +2773,122 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ).toEqual(["recent-session"]); }), ); + + it.effect("imports omp text, title, model and resumable session id", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const ompAgentDir = yield* makeTempDir("t3code-omp-agent-"); + const workspace = yield* makeTempDir("t3code-omp-workspace-"); + + yield* writeTranscript({ + filePath: path.join( + ompAgentDir, + "sessions", + "--mangled--", + "2026-08-23T12-00-00-000Z_01a06f8e.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "title", + v: 1, + title: "Statusbar cache indicator", + source: "auto", + updatedAt: "2026-08-23T13:00:00.000Z", + }), + ompSessionLine({ cwd: workspace, sessionId: "01a06f8e" }).trimEnd(), + encodeTranscriptRecord({ type: "model_change", model: "anthropic/claude-sonnet-4" }), + encodeTranscriptRecord({ type: "thinking_level_change", thinkingLevel: "medium" }), + encodeTranscriptRecord({ + type: "message", + timestamp: "2026-08-23T12:01:00.000Z", + message: { + role: "user", + content: [{ type: "text", text: "Show cache status" }], + attribution: "user", + }, + }), + encodeTranscriptRecord({ + type: "custom_message", + customType: "skill-prompt", + content: "[IMPORTANT: User invoked a skill]", + }), + encodeTranscriptRecord({ + type: "custom", + customType: "tool_execution_start", + data: { toolName: "read", args: { path: "settings.ts" } }, + }), + encodeTranscriptRecord({ + type: "message", + timestamp: "2026-08-23T12:02:00.000Z", + message: { + role: "toolResult", + content: [{ type: "text", text: "file contents" }], + }, + }), + encodeTranscriptRecord({ + type: "message", + timestamp: "2026-08-23T12:03:00.000Z", + message: { + role: "developer", + content: [{ type: "text", text: "todos" }], + }, + }), + encodeTranscriptRecord({ + type: "message", + timestamp: "2026-08-23T12:04:00.000Z", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Consider the statusline" }, + { type: "text", text: "Added the segment" }, + { type: "toolCall", toolName: "write" }, + ], + }, + }), + encodeTranscriptRecord({ type: "model_change", model: "anthropic/claude-opus-5" }), + ].join("\n"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + hostEnvironment: {}, + providerInstances: ompProviderInstances([ + { name: "PI_CODING_AGENT_DIR", value: ompAgentDir }, + ]), + }); + + expect(threads).toEqual([ + { + source: "omp", + providerInstanceId: "omp", + providerSessionId: "01a06f8e", + title: "Statusbar cache indicator", + model: "anthropic/claude-opus-5", + createdAt: "2026-08-23T12:01:00.000Z", + updatedAt: "2026-08-23T12:00:00.000Z", + messages: [ + { + role: "user", + text: "Show cache status", + createdAt: "2026-08-23T12:01:00.000Z", + }, + { + role: "assistant", + text: "Added the segment", + createdAt: "2026-08-23T12:04:00.000Z", + }, + ], + }, + ]); + }), + ); }); }); @@ -3214,4 +3498,127 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages[0]?.text).toBe("Keep this prompt"); expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); }); + + it("prefers the rewritten omp title header over older title records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "title", title: "Newest header title" }), + encodeTranscriptRecord({ + type: "session", + id: "omp-session", + cwd: "C:\\tmp", + title: "Title when the session started", + titleSource: "auto", + }), + encodeTranscriptRecord({ type: "title_change", title: "Renamed mid-session" }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "Hello" }] }, + }), + ].join("\n"), + source: "omp", + providerInstanceId: ProviderInstanceId.make("omp"), + fallbackSessionId: "2026-08-23T12-00-00-000Z_omp-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.title).toBe("Newest header title"); + expect(thread?.providerSessionId).toBe("omp-session"); + }); + + it("falls back to the omp session record title when no header was written", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session", + id: "omp-session", + cwd: "/project", + title: "Title when the session started", + }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "Hello" }] }, + }), + ].join("\n"), + source: "omp", + providerInstanceId: ProviderInstanceId.make("omp"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.title).toBe("Title when the session started"); + }); + + it("keeps the first omp session id when a resumed transcript copies its ancestor", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session", id: "original-session", cwd: "/project" }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "Hello" }] }, + }), + encodeTranscriptRecord({ type: "session", id: "forked-session", cwd: "/project" }), + ].join("\n"), + source: "omp", + providerInstanceId: ProviderInstanceId.make("omp"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("original-session"); + }); + + it("skips an omp transcript whose session record is missing", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "title", title: "Orphan transcript" }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "Hello" }] }, + }), + ].join("\n"), + source: "omp", + providerInstanceId: ProviderInstanceId.make("omp"), + // The filename embeds the id, but only the transcript proves which + // session ACP `session/load` can replay. + fallbackSessionId: "2026-08-23T12-00-00-000Z_omp-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("drops omp user records that carry injected rather than typed text", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session", id: "omp-session", cwd: "/project" }), + encodeTranscriptRecord({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "injected" }], + attribution: "system", + }, + }), + encodeTranscriptRecord({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "Typed by the operator" }], + attribution: "user", + }, + }), + encodeTranscriptRecord({ + type: "message", + message: { role: "fileMention", files: [{ path: "design/", content: "notes.md" }] }, + }), + ].join("\n"), + source: "omp", + providerInstanceId: ProviderInstanceId.make("omp"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual(["Typed by the operator"]); + }); }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..575cca34499b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,10 +1,10 @@ /** * AgentSessionScanner - discovery of projects a user already works on. * - * Claude Code and Codex both keep a per-session transcript on disk, and each - * transcript records the directory the session ran in. Reading those `cwd` - * values gives us the set of directories worth offering as projects during - * onboarding, without asking the user to browse the filesystem. + * Claude Code, Codex and omp each keep a per-session transcript on disk, and + * each transcript records the directory the session ran in. Reading those + * `cwd` values gives us the set of directories worth offering as projects + * during onboarding, without asking the user to browse the filesystem. * * The scan is read-only and best-effort: an unreadable home, a malformed * transcript, or a directory that has since been deleted is skipped rather @@ -95,6 +95,10 @@ const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; const MAX_IMPORT_RECORDS = 100_000; +/** omp's profile name rules, mirrored from its own directory resolver. */ +const OMP_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const WINDOWS_RESERVED_BASENAME_PATTERN = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i; + const TranscriptContentBlock = Schema.Struct({ type: Schema.optional(Schema.String), text: Schema.optional(Schema.String), @@ -104,6 +108,8 @@ const TranscriptMessage = Schema.Struct({ role: Schema.optional(Schema.String), content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), model: Schema.optional(Schema.String), + // omp tags every visible prompt `user`; injected turns carry another value. + attribution: Schema.optional(Schema.String), }); const CodexTurnMetadata = Schema.Struct({ @@ -115,6 +121,11 @@ const TranscriptRecord = Schema.Struct({ timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), + // omp keeps its resumable session id, title and model at the top level of + // the `session`, `title`/`title_change` and `model_change` records. + id: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), aiTitle: Schema.optional(Schema.String), isSidechain: Schema.optional(Schema.Boolean), isMeta: Schema.optional(Schema.Boolean), @@ -183,7 +194,7 @@ export class AgentSessionScanner extends Context.Service< AgentSessionScanner, { /** - * Discover every directory the configured Claude and Codex homes have run + * Discover every directory the configured Claude, Codex and omp homes have run * a session in. Candidates are returned newest-first; the client decides * which ones to import and how far back to look. Fails with the contract * error directly — there is no server-local context worth wrapping. @@ -283,6 +294,18 @@ function codexTurnId(metadata: unknown): string | null { return decoded.value.turn_id; } +/** + * omp's own profile validation, copied so the scanner resolves the same + * agent directory the CLI does. `default`, an empty value and every name omp + * rejects (it throws and then ignores the variable) mean "no profile". + */ +function normalizeOmpProfileName(value: string | undefined): string | null { + const profile = value?.trim() ?? ""; + if (profile.length === 0 || profile === "default") return null; + if (profile.endsWith(".") || !OMP_PROFILE_NAME_PATTERN.test(profile)) return null; + return WINDOWS_RESERVED_BASENAME_PATTERN.test(profile) ? null : profile; +} + /** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ export function parseAgentSessionTranscript( input: AgentSessionTranscriptMetadata & { @@ -301,11 +324,16 @@ function parseAgentSessionRecords( ): AgentSessionThread | null { const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); // Claude filenames are session IDs. Codex rollout filenames include extra - // timestamp text, so only transcript metadata can provide a resumable ID. - let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + // timestamp text, and omp prefixes its own with a start timestamp, so for + // both only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "claudeAgent" ? input.fallbackSessionId : ""; let title: string | null = null; let model: string | null = null; - let hasCodexSessionId = false; + let hasMetadataSessionId = false; + // omp writes the current title three times over: the rewritten-in-place + // header record is newest, then `title_change`, then the `session` record's + // copy of whatever the title was when the session started. + let ompTitleRank = -1; const messages: Array = []; let firstUserMessage: | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) @@ -424,11 +452,62 @@ function parseAgentSessionRecords( continue; } + if (input.source === "omp") { + if (record.type === "session") { + // A resumed session copies its ancestor's header, so the first + // `session` record names the id ACP `session/load` can replay. + const sessionId = record.id?.trim(); + if (!hasMetadataSessionId && sessionId) { + providerSessionId = sessionId; + hasMetadataSessionId = true; + } + const sessionTitle = record.title?.trim(); + if (sessionTitle && ompTitleRank <= 0) { + title = sessionTitle; + ompTitleRank = 0; + } + continue; + } + if (record.type === "title" || record.type === "title_change") { + const rank = record.type === "title" ? 2 : 1; + const nextTitle = record.title?.trim(); + if (nextTitle && rank >= ompTitleRank) { + title = nextTitle; + ompTitleRank = rank; + } + continue; + } + if (record.type === "model_change") { + const nextModel = record.model?.trim(); + if (nextModel) model = nextModel; + continue; + } + if (record.type !== "message") continue; + const role = record.message?.role; + if (role !== "user" && role !== "assistant") continue; + // omp replays tool results and system reminders as `toolResult`, + // `developer` and `fileMention` roles, which the role check already + // drops. A `user` record with another attribution is injected text. + const attribution = record.message?.attribution; + if (role === "user" && attribution !== undefined && attribution !== "user") continue; + // Thinking and tool-call blocks carry no `text`, so only visible + // assistant prose survives extraction. + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if (record.type === "session_meta") { const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); - if (!hasCodexSessionId && sessionId) { + if (!hasMetadataSessionId && sessionId) { providerSessionId = sessionId; - hasCodexSessionId = true; + hasMetadataSessionId = true; } continue; } @@ -525,6 +604,16 @@ function shouldRetainDecodedRecord( record.message?.model !== undefined ); } + if (source === "omp") { + return ( + record.type === "session" || + record.type === "title" || + record.type === "title_change" || + record.type === "model_change" || + (record.type === "message" && + (record.message?.role === "user" || record.message?.role === "assistant")) + ); + } return ( record.type === "session_meta" || record.type === "turn_context" || @@ -921,9 +1010,37 @@ export const make = Effect.gen(function* () { return path.join(NodeOS.homedir(), ".claude"); }; - const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( - function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { - const projectsDir = path.join(homePath, "projects"); + /** + * Resolve the omp agent directory the CLI would use, matching omp's own + * precedence: a profile (`OMP_PROFILE`, else `PI_PROFILE`) roots the agent + * directory under `profiles/` and makes `PI_CODING_AGENT_DIR` + * inert; without a profile that variable, when set, *is* the agent + * directory. The config directory name itself is `PI_CONFIG_DIR` or + * `.omp`. An unusable profile name is ignored by omp rather than fatal. + */ + const resolveOmpAgentDir = (readEnvironment: (name: string) => string | undefined): string => { + const ompProfile = readEnvironment("OMP_PROFILE"); + const profile = normalizeOmpProfileName( + ompProfile === undefined ? readEnvironment("PI_PROFILE") : ompProfile, + ); + const agentDirOverride = readEnvironment("PI_CODING_AGENT_DIR")?.trim() ?? ""; + if (profile === null && agentDirOverride.length > 0) { + return path.resolve(expandHomePath(agentDirOverride)); + } + const configDirName = readEnvironment("PI_CONFIG_DIR")?.trim() || ".omp"; + const configRoot = path.join(NodeOS.homedir(), configDirName); + return profile === null + ? path.join(configRoot, "agent") + : path.join(configRoot, "profiles", profile, "agent"); + }; + + /** + * Claude keeps one directory of transcripts per project slug, and omp one + * per mangled cwd. Neither slug is decoded: the transcripts themselves + * carry the real `cwd`. + */ + const discoverGroupedTranscripts = Effect.fn("AgentSessionScanner.discoverGroupedTranscripts")( + function* (rootDir: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { let operationsRemaining = operationBudget; let truncated = false; const readDirectory = (directory: string) => { @@ -934,7 +1051,7 @@ export const make = Effect.gen(function* () { operationsRemaining -= 1; return listDirectory(directory); }; - const projectDirectories = yield* readDirectory(projectsDir); + const projectDirectories = yield* readDirectory(rootDir); const transcripts: Array = []; for (const projectDirectory of projectDirectories) { @@ -942,7 +1059,7 @@ export const make = Effect.gen(function* () { truncated = true; break; } - const directory = path.join(projectsDir, projectDirectory); + const directory = path.join(rootDir, projectDirectory); const directoryTranscripts = (yield* readDirectory(directory)) .filter((entry) => entry.endsWith(".jsonl")) .map((entry) => path.join(directory, entry)); @@ -1090,7 +1207,7 @@ export const make = Effect.gen(function* () { const raw: Array = []; let truncated = false; - for (const source of ["claudeAgent", "codex"] as const) { + for (const source of ["claudeAgent", "codex", "omp"] as const) { const instances: Array<{ readonly instanceId: ProviderInstanceId; readonly config: ProviderInstanceConfig; @@ -1125,19 +1242,28 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { - const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; - const environmentHome = - instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? - hostEnvironment[homeVariable]; + // The spawned CLI sees the instance's own environment first, then + // whatever this process inherited. + const readEnvironment = (name: string) => + instance.environment?.findLast((variable) => variable.name === name)?.value ?? + hostEnvironment[name]; let homePath: string; if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; - homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + homePath = resolveClaudeConfigDir( + config.value.homePath, + readEnvironment("CLAUDE_CONFIG_DIR"), + ); + } else if (source === "omp") { + // omp has no configurable home: its agent directory is derived + // entirely from the environment the CLI runs with. + homePath = resolveOmpAgentDir(readEnvironment); } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; + const environmentHome = readEnvironment("CODEX_HOME"); const codexSettings = config.value.homePath.trim().length === 0 && config.value.shadowHomePath.trim().length === 0 && @@ -1167,9 +1293,13 @@ export const make = Effect.gen(function* () { truncated = true; continue; } - const discovered = yield* source === "claudeAgent" - ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) - : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + const discovered = yield* source === "codex" + ? discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverGroupedTranscripts( + path.join(home.homePath, source === "claudeAgent" ? "projects" : "sessions"), + home.providerInstanceId, + operationBudget, + ); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f33a9cce63b0..0c5739927e2d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -31,6 +31,7 @@ import { type EnvironmentId, type EnvironmentMachineKind, type FilesystemBrowseResult, + isProviderDriverKind, type ProjectId, type SourceControlDiscoveryResult, type SourceControlProviderKind, @@ -166,6 +167,7 @@ import { import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { resolveThreadProviderDisplayName } from "../providerModels"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; @@ -1247,6 +1249,16 @@ function OpenCommandPaletteDialog(props: { providerEntryByEnvironmentAndInstanceId.get( `${thread.environmentId}:${modelInstanceId}`, ) ?? null; + // Configured instance label wins over the persisted driver slug + // (`thread.session.providerName`); the resolver formats the slug + // (brand label, legacy `piAgent` alias, humanized fallback) when no + // catalog entry exists. Driver falls back to the session slug so a + // historical thread without a catalog entry still shows its harness. + const sessionDriverKind = + thread.session?.providerName != null && + isProviderDriverKind(thread.session.providerName) + ? thread.session.providerName + : null; return ( ); }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cc3487b595ed..283d4b33bee4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -99,6 +99,7 @@ import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; +import { resolveThreadProviderDisplayName } from "../providerModels"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, @@ -386,9 +387,11 @@ function SidebarThreadTooltip({
{ expect(markup).toContain(">Repo"); expect(markup).toContain("Find the right skill or workflow"); }); + + it("badges a provider command that collides with a built-in label", () => { + const seen: Array<{ id: string; type: string }> = []; + const markup = renderToStaticMarkup( + {}} + onSelect={(item) => { + seen.push({ id: item.id, type: item.type }); + }} + />, + ); + + // Both rows keep the colliding "/model" label with their own description, + // so the data the renderer receives still dispatches distinctly. + expect(markup).toContain("Switch response model for this thread"); + expect(markup).toContain("Switch the omp model"); + expect(markup).toContain('data-composer-item-id="slash:model"'); + expect(markup).toContain('data-composer-item-id="provider-slash-command:omp:model"'); + // The provider row carries its brand badge; the built-in row has no badge. + expect(markup).toContain(">Oh My Pi"); + const badgeCount = markup.split('data-slot="badge"').length - 1; + expect(badgeCount).toBe(1); + }); }); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9cdfc37a329f..c94adaac9e22 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -21,6 +21,7 @@ import { import { memo, useLayoutEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { formatProviderDriverKindLabel } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; @@ -156,6 +157,14 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null; const pullRequestPresentation = props.item.type === "pull-request" ? resolvePullRequestState(props.item.pullRequest) : null; + // Provider-supplied slash commands share labels with T3 built-ins (`/model` + // from omp/Cursor/Grok/OpenCode vs T3's own `/model`). The badge carries the + // provider's brand label so colliding rows read distinctly; dispatch still + // uses the item's `type`/`provider`/`command`, untouched below. + const providerSlashCommandLabel = + props.item.type === "provider-slash-command" + ? formatProviderDriverKindLabel(props.item.provider) + : null; return ( ) : null} + {providerSlashCommandLabel ? ( + + {providerSlashCommandLabel} + + ) : null} ); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..07744c702e15 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -62,7 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus"; import { getDriverOption } from "../settings/providerDriverMeta"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; -import { ClaudeAI, OpenAI } from "../Icons"; +import { ClaudeAI, OpenAI, PiAgentIcon } from "../Icons"; import { T3Wordmark } from "../T3Wordmark"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -1480,7 +1480,7 @@ function ImportRowMeta({ threadCount, lastActiveAt, }: { - readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly sources: ReadonlyArray<"claudeAgent" | "codex" | "omp"> | null; readonly threadCount: number; readonly lastActiveAt: string | null; }) { @@ -1488,7 +1488,7 @@ function ImportRowMeta({ // "just now" does not fit the fixed column, so collapse it. const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; return ( - + {sources?.includes("claudeAgent") ? ( @@ -1497,6 +1497,9 @@ function ImportRowMeta({ {sources?.includes("codex") ? : null} + + {sources?.includes("omp") ? : null} + {threadCount} {age} diff --git a/apps/web/src/providerModels.test.ts b/apps/web/src/providerModels.test.ts index d28cc559062e..bc8bba3fe0f8 100644 --- a/apps/web/src/providerModels.test.ts +++ b/apps/web/src/providerModels.test.ts @@ -5,7 +5,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getProviderModelCapabilities } from "./providerModels"; +import { + formatProviderDriverKindLabel, + getProviderModelCapabilities, + resolveThreadProviderDisplayName, +} from "./providerModels"; const PROVIDER = ProviderDriverKind.make("claudeAgent"); @@ -75,3 +79,46 @@ describe("getProviderModelCapabilities", () => { }); }); }); + +describe("formatProviderDriverKindLabel", () => { + it("maps the omp slug to its configured display name", () => { + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("omp"))).toBe("Oh My Pi"); + }); + + it("maps the legacy piAgent slug to the same display name", () => { + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("piAgent"))).toBe("Oh My Pi"); + }); + + it("prefers brand labels over humanized slugs for every driver", () => { + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("claudeAgent"))).toBe("Claude"); + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("opencode"))).toBe("OpenCode"); + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("codex"))).toBe("Codex"); + }); + + it("humanizes an unmapped fork slug", () => { + expect(formatProviderDriverKindLabel(ProviderDriverKind.make("myCustomDriver"))).toBe( + "My Custom Driver", + ); + }); +}); + +describe("resolveThreadProviderDisplayName", () => { + it("prefers the configured display name over the driver slug", () => { + expect( + resolveThreadProviderDisplayName({ + configuredDisplayName: "Oh My Pi", + sessionProviderName: "omp", + fallbackInstanceId: "omp", + }), + ).toBe("Oh My Pi"); + }); + + it("formats the session slug when no catalog entry exists", () => { + expect(resolveThreadProviderDisplayName({ sessionProviderName: "omp" })).toBe("Oh My Pi"); + expect(resolveThreadProviderDisplayName({ sessionProviderName: "piAgent" })).toBe("Oh My Pi"); + }); + + it("humanizes an unmapped slug without a configured name", () => { + expect(resolveThreadProviderDisplayName({ fallbackInstanceId: "my_custom" })).toBe("My Custom"); + }); +}); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 568e2b839b91..5610f719facf 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -2,6 +2,8 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, + isProviderDriverKind, + PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ModelCapabilities, type ProviderInstanceId, @@ -15,12 +17,60 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); -export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string { - return provider +// Brand labels for driver slugs the contracts map does not cover (yet). `piAgent` +// is the pre-rename Pi driver slug still present in persisted thread sessions; +// both it and `omp` must read as the configured "Oh My Pi" display name. Kept +// beside the formatter (not in contracts) so web can cover legacy slugs +// without widening the server's driver union. +const LEGACY_PROVIDER_DRIVER_KIND_LABELS: Readonly> = { + piAgent: "Oh My Pi", + omp: "Oh My Pi", +}; + +function humanizeProviderSlug(slug: string): string { + const humanized = slug .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/[_-]+/g, " ") .trim() .replace(/\b\w/g, (char) => char.toUpperCase()); + return humanized.length > 0 ? humanized : slug; +} + +// User-facing driver label. Brand names win (contracts map, then legacy +// aliases); unknown/fork slugs fall back to a humanized slug so every driver +// renders something readable instead of its raw id. +export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string { + return ( + PROVIDER_DISPLAY_NAMES[provider] ?? + LEGACY_PROVIDER_DRIVER_KIND_LABELS[provider] ?? + humanizeProviderSlug(provider) + ); +} + +function formatDriverSlugLabel(slug: string): string { + const trimmed = slug.trim(); + if (trimmed.length === 0) return trimmed; + if (isProviderDriverKind(trimmed)) return formatProviderDriverKindLabel(trimmed); + return LEGACY_PROVIDER_DRIVER_KIND_LABELS[trimmed] ?? humanizeProviderSlug(trimmed); +} + +// Thread-row provider name with the configured instance label winning over the +// driver slug. `configuredDisplayName` is the catalog entry's already-resolved +// label; `sessionProviderName` is the persisted driver slug +// (`thread.session.providerName`); `fallbackInstanceId` is the thread's model +// routing key for threads with no catalog entry and no session binding. +export function resolveThreadProviderDisplayName(input: { + readonly configuredDisplayName?: string | null | undefined; + readonly sessionProviderName?: string | null | undefined; + readonly fallbackInstanceId?: string | ProviderInstanceId | null | undefined; +}): string { + const configured = input.configuredDisplayName?.trim(); + if (configured) return configured; + const sessionSlug = input.sessionProviderName?.trim(); + if (sessionSlug) return formatDriverSlugLabel(sessionSlug); + const fallback = input.fallbackInstanceId?.trim(); + if (fallback) return formatDriverSlugLabel(fallback); + return ""; } export function getProviderModels( diff --git a/docs/user/install.md b/docs/user/install.md index b1835f57e99d..d52cb6cdaa46 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -119,8 +119,8 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and -[Antigravity](./providers-antigravity.md). +[Claude](./providers-claude.md), [Oh My Pi](./providers-oh-my-pi.md), +[OpenCode](./providers-opencode.md), and [Antigravity](./providers-antigravity.md). ## Next steps diff --git a/docs/user/providers-oh-my-pi.md b/docs/user/providers-oh-my-pi.md new file mode 100644 index 000000000000..25ce6156d69d --- /dev/null +++ b/docs/user/providers-oh-my-pi.md @@ -0,0 +1,53 @@ +# Oh My Pi (omp) + +Oh My Pi runs as T3 Code's agent through its own ACP server (`omp acp`). T3 Code +starts one omp process per thread in that thread's project directory, so +everything omp reads from your machine still applies: the credentials under +`~/.omp`, your `models.yml`, skills, plugins, extensions, hooks, rules, +`AGENTS.md`, and MCP servers. T3 Code manages no keys of its own for omp. + +## Setup + +Install [Oh My Pi](https://github.com/can1357/oh-my-pi), run `omp` once to sign +in, then enable **Oh My Pi** in **Settings → Providers**. The provider card shows +the detected version, how many upstream providers your omp config reaches, and +the accounts omp is authenticated with. If `omp` is not on the server's `PATH`, +set **Binary path** on the card. + +When omp is behind its latest release, the card offers **Update now**, which runs +omp's own updater (`omp update`). No other installer is involved. + +## What carries over from the terminal + +| Terminal feature | In T3 Code | +| ------------------------------- | ---------------------------------------------------------------------------- | +| Skills | `$name` in the composer; the menu lists every skill omp discovered | +| Slash commands | `/` lists omp's own commands, including plugin and project commands | +| Models and upstreams | Model picker groups omp models by upstream provider; switching is in-session | +| Thinking levels | Reasoning selector, limited to the levels omp accepts for that model | +| Approvals | Approval prompts in the thread; the mode maps to omp's approval flags | +| Questions from skills and hooks | User-input requests in the thread | +| Subagents (`task`) | Agents panel, with per-agent progress and outcome | +| Todos | Plan panel, updated as omp rewrites its list | +| Context and cost | Context meter in the composer, fed by omp's own usage reports | +| `/compact` | **Compact** button, which runs omp's compaction | +| Usage limits | Quota banner from `omp usage` | +| Sessions started in a terminal | Imported as threads and resumed in place | + +Commands that only make sense in a terminal have a T3 Code equivalent instead: +the fullscreen git UI becomes the git panel and commit composer, `omp shell` and +PTY work become the terminal drawer, and Ctrl+P model cycling becomes the model +picker. + +## Limits + +- **No rewind.** omp can branch a session from an earlier entry in its terminal + UI, but its ACP interface exposes no such call, so T3 Code cannot revert a + conversation to an earlier turn. Fork the thread and continue instead. +- **Skill files are not openable.** omp reports a skill by name, not by path, so + a skill chip shows its description without a "view instructions" link. +- **Menus follow the project.** Project-scoped skills and commands come from the + thread's project directory; a thread in another project sees another set. +- **Approval behavior is chosen when omp starts.** omp takes it from launch + flags, so a thread's permission mode applies from the next turn onward rather + than to work already in flight. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index 91c181175f3b..1539c327f18c 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -3,7 +3,7 @@ import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from ". import { ProviderInstanceId } from "./providerInstance.ts"; /** Coding agent home directories the scanner knows how to read. */ -export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex", "omp"]); export type AgentSessionSource = typeof AgentSessionSource.Type; /** File identity saved with an imported session so bounded retries can skip unchanged history. */ From 9c5c3f742ce3b17623d55e82ca83752f92934959 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 12:46:00 -0300 Subject: [PATCH 09/22] feat(omp): model catalog from omp metadata, account email, machine-level command catalog --- .../src/provider/Drivers/OmpCommands.test.ts | 79 ++++- .../src/provider/Drivers/OmpCommands.ts | 71 ++-- .../src/provider/Drivers/OmpDriver.test.ts | 27 +- .../provider/Drivers/OmpModelCatalog.test.ts | 203 ++++++++++++ .../src/provider/Drivers/OmpModelCatalog.ts | 283 ++++++++++++++++ .../src/provider/Drivers/OmpUsage.test.ts | 12 + apps/server/src/provider/Drivers/OmpUsage.ts | 16 + .../src/provider/Layers/OmpProvider.test.ts | 304 +++++++++++++----- .../server/src/provider/Layers/OmpProvider.ts | 295 +++++------------ 9 files changed, 962 insertions(+), 328 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OmpModelCatalog.test.ts create mode 100644 apps/server/src/provider/Drivers/OmpModelCatalog.ts diff --git a/apps/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts index 3a995ab5d610..90b3f8451089 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.test.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -1,6 +1,18 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { describe, expect, it } from "vite-plus/test"; -import { catalogFromCommandEntries, decodeOmpCommandCatalog } from "./OmpCommands.ts"; +import { + catalogFromCommandEntries, + decodeOmpCommandCatalog, + discoverOmpCommandCatalog, +} from "./OmpCommands.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; const frame = (commands: ReadonlyArray) => // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. @@ -164,3 +176,68 @@ describe("catalogFromCommandEntries", () => { expect(catalogFromCommandEntries(entries)).toEqual(decodeOmpCommandCatalog(frame(entries))); }); }); + +/** + * Fake omp answering `--mode rpc`: it emits the startup command frame, then + * replies to whatever request arrives on stdin, and records each spawn so the + * test can prove one process served both catalogs. + */ +const fakeRpcOmpSource = (spawnLogPath: string) => + [ + 'import { appendFileSync } from "node:fs";', + `appendFileSync(${JSON.stringify(spawnLogPath)}, "spawn\\n");`, + `process.stdout.write(${JSON.stringify( + `${frame([ + { name: "compact", description: "Compact the context" }, + { name: "skill:deploy", description: "Deploy the app" }, + ])}\n`, + )});`, + "const chunks = [];", + "for await (const chunk of process.stdin) chunks.push(chunk);", + 'const request = JSON.parse(Buffer.concat(chunks).toString("utf8").trim());', + 'if (request.type === "get_available_models") {', + " process.stdout.write(", + " JSON.stringify({", + " id: request.id,", + ' type: "response",', + ' command: "get_available_models",', + " data: {", + " models: [", + ' { id: "claude-sonnet-5", name: "Claude Sonnet 5", provider: "anthropic", reasoning: true, contextWindow: 1000000, thinking: { mode: "anthropic-adaptive", efforts: ["low", "high"] } },', + " ],", + " },", + ' }) + "\\n",', + " );", + "}", + "process.exit(0);", + "", + ].join("\n"); + +describe("discoverOmpCommandCatalog", () => { + effectIt.live("answers both catalogs from a single omp process", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-rpc-catalog-", + }); + const spawnLogPath = path.join(directory, "spawns.log"); + const binaryPath = writeFakeCli({ + directory, + name: "fake-omp", + source: fakeRpcOmpSource(spawnLogPath), + }); + + const catalog = yield* discoverOmpCommandCatalog({ binaryPath }); + + expect(catalog.slashCommands.map((command) => command.name)).toEqual(["compact"]); + expect(catalog.skills.map((skill) => skill.name)).toEqual(["deploy"]); + expect(catalog.models.metadataBySlug.get("anthropic/claude-sonnet-5")?.contextWindow).toBe( + 1_000_000, + ); + const spawnLog = yield* fileSystem.readFileString(spawnLogPath); + expect(spawnLog.trim().split("\n")).toHaveLength(1); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Drivers/OmpCommands.ts b/apps/server/src/provider/Drivers/OmpCommands.ts index 2960cfd4b49b..4d2e64812691 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -12,14 +12,15 @@ * `sessionId`, `configOptions` and `modes`, verified against omp/18.1.18), but * RPC mode emits an `available_commands_update` frame at startup carrying * every command: one `skill:` per discovered skill plus the regular - * commands. The probe therefore spawns `omp --mode rpc`, closes stdin - * immediately, and reads that frame: on stdin close RPC drains accepted - * commands, disposes the session and exits 0, so no request has to be written - * and no model is ever called. + * commands. The same process also answers `get_available_models` with omp's + * full model metadata, so one spawn serves both catalogs: the probe writes + * that single request, closes stdin, and reads the transcript. On stdin close + * RPC drains accepted commands, disposes the session and exits 0, and no + * model is ever called. * - * Both kinds reach omp as ordinary prompt text — `/tools` and `/skill:` - * were both verified to run over an ACP `session/prompt` — so discovery is - * only about offering them in the menus. + * Both command kinds reach omp as ordinary prompt text — `/tools` and + * `/skill:` were both verified to run over an ACP `session/prompt` — so + * command discovery is only about offering them in the menus. * * @module provider/Drivers/OmpCommands */ @@ -36,6 +37,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectStreamAsString } from "../providerSnapshot.ts"; +import { + decodeOmpModelCatalog, + OMP_AVAILABLE_MODELS_REQUEST_ID, + type OmpModelCatalog, +} from "./OmpModelCatalog.ts"; /** * Startup covers config load, skill discovery, extension load and MCP @@ -50,6 +56,15 @@ export interface OmpCommandCatalog { readonly slashCommands: ReadonlyArray; } +/** + * What one RPC probe returns. `OmpCommandCatalog` stays the shape the live + * session path produces (`available_commands_update` frames carry no model + * metadata), so command consumers are unaffected by the model catalog. + */ +export interface OmpRpcCatalog extends OmpCommandCatalog { + readonly models: OmpModelCatalog; +} + export class OmpCommandsProbeError extends Schema.TaggedError()( "OmpCommandsProbeError", { @@ -155,22 +170,23 @@ export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { } /** - * Spawn `omp --mode rpc` in `cwd` and map its startup catalog onto provider - * skills and slash commands. Project-scoped skills and commands live under the - * workspace, so the cwd decides the result and every workspace needs its own - * probe. + * Spawn `omp --mode rpc` in `cwd` once and map its transcript onto provider + * skills, slash commands and omp's model catalog. Project-scoped skills and + * commands live under the workspace, so the cwd decides the command result and + * every workspace needs its own probe; the model catalog is workspace-scoped + * too, because a project `models.yml` overlay can add entries. */ export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")(function* ( ompSettings: Pick, environment: NodeJS.ProcessEnv = process.env, cwd?: string, -) { +): Effect.fn.Return { const command = ompSettings.binaryPath || "omp"; const probe = yield* Effect.gen(function* () { const spawnCommand = yield* resolveSpawnCommand( command, - // No session file, no language servers: the probe only needs the command - // catalog, and both would cost startup time and leave state behind. + // No session file, no language servers: the probe only needs the two + // catalogs, and both would cost startup time and leave state behind. ["--mode", "rpc", "--no-session", "--no-lsp"], { env: environment }, ); @@ -182,12 +198,29 @@ export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")( shell: spawnCommand.shell, }), ); - // Closing stdin is the exit signal: RPC mode keeps reading commands until - // stdin ends, so without this the process outlives the probe. + // One request, then stdin closes: commands arrive unprompted at startup, + // the model catalog has to be asked for, and closing stdin is the exit + // signal — RPC mode keeps reading commands until stdin ends, so without it + // the process outlives the probe. omp answers the queued request before it + // drains, so the response lands in the same transcript. const [stdout, , exitCode] = yield* Effect.all( [ collectStreamAsString(child.stdout), - Stream.run(Stream.empty, child.stdin), + // Best-effort: a probe that already exited (or an omp build that + // closed stdin first) must not fail the whole catalog — stdout and the + // exit code decide the outcome below. + Stream.run( + Stream.encodeText( + Stream.make( + // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. + `${JSON.stringify({ + id: OMP_AVAILABLE_MODELS_REQUEST_ID, + type: "get_available_models", + })}\n`, + ), + ), + child.stdin, + ).pipe(Effect.ignore), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, @@ -213,9 +246,11 @@ export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")( }); } const catalog = decodeOmpCommandCatalog(probe.value.stdout); + const models = decodeOmpModelCatalog(probe.value.stdout); if ( catalog.skills.length === 0 && catalog.slashCommands.length === 0 && + models.models.length === 0 && probe.value.exitCode !== 0 ) { return yield* new OmpCommandsProbeError({ @@ -224,5 +259,5 @@ export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")( exitCode: probe.value.exitCode, }); } - return catalog; + return { ...catalog, models }; }); diff --git a/apps/server/src/provider/Drivers/OmpDriver.test.ts b/apps/server/src/provider/Drivers/OmpDriver.test.ts index 7de0908cbc12..f257c1084491 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.test.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.test.ts @@ -84,7 +84,12 @@ function fakeOmpSource(input: { "}", 'if (args[0] === "--mode") {', ...(input.probeLogPath - ? [` appendFileSync(${JSON.stringify(input.probeLogPath)}, "probe\\n");`] + ? [ + // The machine-level status probe runs from the server's own cwd on an + // interval nobody here controls, so each spawn records its cwd and the + // assertions count only the workspace they asked for. + ` appendFileSync(${JSON.stringify(input.probeLogPath)}, process.cwd() + "\\n");`, + ] : []), ` process.stdout.write(${JSON.stringify(`${JSON.stringify({ type: "available_commands_update", commands: catalogCommands })}\n`)});`, " process.exit(0);", @@ -148,13 +153,20 @@ interface CapturedOmpAdapterOptions { const lastAdapterOptions = (): CapturedOmpAdapterOptions | undefined => capturedAdapterOptions.at(-1) as CapturedOmpAdapterOptions | undefined; -const readProbeCount = (probeLogPath: string) => +const readProbeCount = (probeLogPath: string, cwd?: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const text = yield* fileSystem .readFileString(probeLogPath) .pipe(Effect.orElseSucceed(() => "")); - return text.split("\n").filter((line) => line.length > 0).length; + const lines = text.split("\n").filter((line) => line.trim().length > 0); + if (cwd === undefined) return lines.length; + const expected = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + return lines.filter((line) => { + const normalized = path.normalize(line.trim()); + return normalized === path.normalize(expected) || normalized === path.normalize(cwd); + }).length; }); it.layer(testLayer)("OmpDriver", (it) => { @@ -329,11 +341,12 @@ it.layer(testLayer)("OmpDriver", (it) => { const second = yield* snapshotForCwd(workspace); expect(second.skills).toEqual(first.skills); expect(second.slashCommands).toEqual(first.slashCommands); - expect(yield* readProbeCount(probeLogPath)).toBe(1); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); const other = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-cache-other-" }); yield* snapshotForCwd(other); - expect(yield* readProbeCount(probeLogPath)).toBe(2); + expect(yield* readProbeCount(probeLogPath, other)).toBe(1); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); }).pipe(Effect.scoped), ); @@ -358,7 +371,7 @@ it.layer(testLayer)("OmpDriver", (it) => { const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-live-ws-" }); const first = yield* snapshotForCwd(workspace); expect(first.skills.map((skill) => skill.name)).toEqual(["deploy"]); - expect(yield* readProbeCount(probeLogPath)).toBe(1); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); const options = lastAdapterOptions(); const onSessionCommands = options?.onSessionCommands; @@ -382,7 +395,7 @@ it.layer(testLayer)("OmpDriver", (it) => { ?.skills.map((skill) => skill.name), ).toEqual(["fresh"]); // The live payload replaced the cached probe instead of re-spawning it. - expect(yield* readProbeCount(probeLogPath)).toBe(1); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts new file mode 100644 index 000000000000..8c35cb3695ae --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildOmpModelCapabilities, + catalogFromOmpModelEntries, + decodeOmpModelCatalog, +} from "./OmpModelCatalog.ts"; + +/** + * Trimmed copies of real `get_available_models` entries (omp/18.1.18): a + * 200,000-token non-reasoning model, a 1,000,000-token adaptive model and an + * `effort` model whose ladder starts at `medium`, all in one catalog. + */ +const ompModelEntries = [ + { + id: "claude-3-5-sonnet-20240620", + name: "Claude Sonnet 3.5", + provider: "anthropic", + reasoning: false, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200_000, + maxTokens: 8192, + identity: { class: "anthropic", family: "sonnet", revision: "3.5.0" }, + }, + { + id: "claude-fable-5", + name: "Claude Fable 5", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_000_000, + maxTokens: 128_000, + thinking: { + mode: "anthropic-adaptive", + efforts: ["low", "medium", "high", "xhigh", "max"], + supportsDisplay: true, + }, + }, + { + id: "glm-5.3", + name: "GLM 5.3", + provider: "opencode-go", + reasoning: true, + input: ["text"], + contextWindow: 204_800, + thinking: { mode: "effort", efforts: ["low", "high", "max"], defaultLevel: "max" }, + }, +] as const; + +const rpcTranscript = [ + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. + JSON.stringify({ type: "ready" }), + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. + JSON.stringify({ + type: "available_commands_update", + commands: [{ name: "compact", description: "Compact the context" }], + }), + "not json", + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. + JSON.stringify({ + type: "response", + command: "get_available_models", + data: { models: ompModelEntries }, + }), +].join("\n"); + +describe("catalogFromOmpModelEntries", () => { + it("reports each model's own context window, not one session-wide value", () => { + const catalog = catalogFromOmpModelEntries(ompModelEntries); + + expect( + [...catalog.metadataBySlug.values()].map((entry) => [entry.slug, entry.contextWindow]), + ).toEqual([ + ["anthropic/claude-3-5-sonnet-20240620", 200_000], + ["anthropic/claude-fable-5", 1_000_000], + ["opencode-go/glm-5.3", 204_800], + ]); + expect(catalog.metadataBySlug.get("anthropic/claude-fable-5")).toEqual({ + slug: "anthropic/claude-fable-5", + contextWindow: 1_000_000, + maxTokens: 128_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + }); + + it("keys models by the ACP slug omp accepts and labels the upstream provider", () => { + const catalog = catalogFromOmpModelEntries(ompModelEntries); + + // The ACP `model` select advertises `/`; the bare + // `get_available_models` id is not a value omp would accept back. + expect(catalog.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-fable-5", + "anthropic/claude-3-5-sonnet-20240620", + "opencode-go/glm-5.3", + ]); + expect(catalog.models.map((model) => model.subProvider)).toEqual([ + "Anthropic", + "Anthropic", + "Opencode Go", + ]); + expect(catalog.models.every((model) => model.isCustom)).toBe(false); + }); + + it("offers each model its own reasoning ladder plus omp's off and auto", () => { + const catalog = catalogFromOmpModelEntries(ompModelEntries); + const bySlug = new Map(catalog.models.map((model) => [model.slug, model])); + + expect(bySlug.get("anthropic/claude-fable-5")?.capabilities).toEqual({ + optionDescriptors: [ + { + id: "reasoning", + label: "Thinking", + type: "select", + options: [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], + }, + ], + }); + // A narrower ladder must not gain levels from its catalog neighbours, and + // omp's own `defaultLevel` decides the picker's current value. + expect(bySlug.get("opencode-go/glm-5.3")?.capabilities).toEqual({ + optionDescriptors: [ + { + id: "reasoning", + label: "Thinking", + type: "select", + currentValue: "max", + options: [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + { id: "max", label: "Max", isDefault: true }, + ], + }, + ], + }); + }); + + it("reports no reasoning levels for a model omp calls non-reasoning", () => { + const catalog = catalogFromOmpModelEntries(ompModelEntries); + const bySlug = new Map(catalog.models.map((model) => [model.slug, model])); + + expect( + catalog.metadataBySlug.get("anthropic/claude-3-5-sonnet-20240620")?.reasoningEfforts, + ).toEqual([]); + expect(bySlug.get("anthropic/claude-3-5-sonnet-20240620")?.capabilities).toEqual({ + optionDescriptors: [], + }); + }); + + it("drops ladder values T3 cannot write back and duplicate slugs", () => { + const catalog = catalogFromOmpModelEntries([ + { + id: "future-model", + name: "Future", + provider: "acme", + reasoning: true, + thinking: { mode: "effort", efforts: ["low", "ludicrous", "extra-high"] }, + }, + { id: "future-model", name: "Future (again)", provider: "acme", reasoning: false }, + { name: "no id", provider: "acme" }, + ]); + + expect(catalog.models.map((model) => model.name)).toEqual(["Future"]); + expect(catalog.metadataBySlug.get("acme/future-model")?.reasoningEfforts).toEqual([ + "low", + "xhigh", + ]); + }); +}); + +describe("buildOmpModelCapabilities", () => { + it("emits no descriptor at all when there is no ladder", () => { + expect(buildOmpModelCapabilities({ reasoningEfforts: [] })).toEqual({ optionDescriptors: [] }); + }); +}); + +describe("decodeOmpModelCatalog", () => { + it("reads the model response out of a mixed RPC transcript", () => { + const catalog = decodeOmpModelCatalog(rpcTranscript); + + expect(catalog.models).toHaveLength(3); + expect(catalog.metadataBySlug.get("anthropic/claude-fable-5")?.contextWindow).toBe(1_000_000); + }); + + it("returns an empty catalog when omp answered no model response", () => { + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. + const catalog = decodeOmpModelCatalog(JSON.stringify({ type: "ready" })); + + expect(catalog.models).toEqual([]); + expect(catalog.metadataBySlug.size).toBe(0); + }); +}); diff --git a/apps/server/src/provider/Drivers/OmpModelCatalog.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.ts new file mode 100644 index 000000000000..4c5c381e6769 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.ts @@ -0,0 +1,283 @@ +/** + * OmpModelCatalog — omp's own model metadata, mapped onto provider models. + * + * omp is a meta provider: its catalog is whatever the user configured inside + * omp (built-ins plus `models.yml` entries), and each entry carries real + * metadata. The ACP `model` config option only advertises `value`/`name` + * pairs, so a catalog built from it cannot tell a 200,000-token model from a + * 1,000,000-token one and cannot report a model's reasoning ladder before it + * is selected. RPC mode can: `{"type":"get_available_models"}` answers with + * every model's `contextWindow`, `maxTokens`, `input` modalities and + * `thinking.efforts` (verified against omp/18.1.18, 121 entries). + * + * Two shapes are load-bearing and were verified live rather than assumed: + * + * - The ACP model select value is `/`, not the bare `id` that + * `get_available_models` reports (`anthropic/claude-fable-5`). The slug must + * be the ACP value, because the adapter writes it back into the `model` + * config option. + * - The ACP `thinking` select is `off`, `auto`, then the model's + * `thinking.efforts` in order — `off`/`auto` are added by omp and are not in + * the metadata. A model with `reasoning: false` advertises only `off`/`auto` + * (checked with `omp acp --model anthropic/claude-3-haiku-20240307`), i.e. + * no reasoning levels at all, so those models get no reasoning descriptor. + * + * Deliberately not carried over: `cost` (ACP `usage_update` already reports + * omp's own computed turn cost, so per-token prices have no consumer), + * `maxContextWindow`/`contextPromotionTarget` (omp's context-promotion + * ceiling, which is not the window a turn is measured against), and the + * `compat`/`identity`/`tokenizer` blocks (omp-internal request shaping). + * + * @module provider/Drivers/OmpModelCatalog + */ +import type { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { buildSelectOptionDescriptor } from "../providerSnapshot.ts"; + +/** The single RPC request that answers the whole catalog. */ +export const OMP_AVAILABLE_MODELS_REQUEST_ID = "t3-model-catalog"; + +/** + * omp's thinking ladder, in ascending order, with the picker labels T3 uses. + * `off`/`auto` are session-level values omp adds to every model's select; + * the rest mirror `thinking.efforts`. + */ +const OMP_REASONING_LABELS: Record = { + off: "Off", + auto: "Auto", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +/** + * Normalize an omp thinking value onto the picker id T3 stores. omp's own + * ladder is `off|minimal|low|medium|high|xhigh|max|auto`; ACP selects may + * advertise aliases (`none`, `extra-high`) that must collapse onto one id so + * the selection round-trips back to the raw advertised value. + */ +export function normalizeOmpReasoningValue(value: string | null | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + switch (normalized) { + case "off": + case "none": + return "off"; + case "auto": + return "auto"; + case "minimal": + case "low": + case "medium": + case "high": + case "max": + return normalized; + case "xhigh": + case "extra-high": + case "extra high": + return "xhigh"; + default: + return undefined; + } +} + +/** `zhipu-coding-plan` → `Zhipu Coding Plan`, for upstream-provider labels. */ +export 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(" "); +} + +/** + * Per-model facts omp reports that no `ServerProviderModel` or + * `ModelCapabilities` field can hold. The context meter needs + * {@link OmpModelMetadata.contextWindow}: it is the ceiling omp itself divides + * by, and it differs 5x across one catalog (200,000 vs 1,000,000). + */ +export interface OmpModelMetadata { + readonly slug: string; + readonly contextWindow?: number; + readonly maxTokens?: number; + /** omp's `input` list: `text`, `image`, `audio`, … */ + readonly inputModalities: ReadonlyArray; + /** Normalized `thinking.efforts`; empty for a model omp reports as non-reasoning. */ + readonly reasoningEfforts: ReadonlyArray; +} + +export interface OmpModelCatalog { + readonly models: ReadonlyArray; + readonly metadataBySlug: ReadonlyMap; +} + +export const EMPTY_OMP_MODEL_CATALOG: OmpModelCatalog = { + models: [], + metadataBySlug: new Map(), +}; + +function trimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function stringList(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + const items: Array = []; + for (const entry of value) { + const trimmed = trimmedString(entry); + if (trimmed.length > 0 && !items.includes(trimmed)) { + items.push(trimmed); + } + } + return items; +} + +function reasoningEffortsFromThinking(model: Record): ReadonlyArray { + if (model.reasoning !== true) { + return []; + } + const thinking = model.thinking; + if (typeof thinking !== "object" || thinking === null) { + return []; + } + const efforts: Array = []; + for (const raw of stringList((thinking as Record).efforts)) { + // Unknown ladder values are dropped rather than offered: a value T3 cannot + // normalize would be written back to omp as nothing at all, so the picker + // must not present it as a choice. + const normalized = normalizeOmpReasoningValue(raw); + if ( + normalized && + normalized !== "off" && + normalized !== "auto" && + !efforts.includes(normalized) + ) { + efforts.push(normalized); + } + } + return efforts; +} + +/** + * Build the picker capabilities for one omp model. The reasoning descriptor + * mirrors the ACP `thinking` select omp will advertise once the model is + * selected (`off`, `auto`, then the ladder), so every offered level is a level + * omp accepts. A model with no ladder gets no descriptor: omp offers it only + * `off`/`auto`, which is not a reasoning choice. + */ +export function buildOmpModelCapabilities(input: { + readonly reasoningEfforts: ReadonlyArray; + readonly defaultLevel?: string | undefined; +}): ModelCapabilities { + if (input.reasoningEfforts.length === 0) { + return createModelCapabilities({ optionDescriptors: [] }); + } + const defaultLevel = normalizeOmpReasoningValue(input.defaultLevel); + const values = ["off", "auto", ...input.reasoningEfforts]; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: "Thinking", + options: values.map((value) => ({ + value, + label: OMP_REASONING_LABELS[value] ?? titleCaseSlug(value), + ...(value === defaultLevel ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +/** + * Fold raw `get_available_models` entries into provider models plus the + * metadata T3's contracts have no field for. Entries are keyed by the ACP + * slug (`/`) and sorted by display name: the catalog is 121 + * entries deep on a default install. + */ +export function catalogFromOmpModelEntries(entries: ReadonlyArray): OmpModelCatalog { + const models: Array = []; + const metadataBySlug = new Map(); + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const model = entry as Record; + const id = trimmedString(model.id); + if (id.length === 0) continue; + const provider = trimmedString(model.provider); + const slug = provider.length > 0 ? `${provider}/${id}` : id; + if (metadataBySlug.has(slug)) continue; + const reasoningEfforts = reasoningEffortsFromThinking(model); + const thinking = ( + typeof model.thinking === "object" && model.thinking !== null ? model.thinking : {} + ) as Record; + const contextWindow = positiveInteger(model.contextWindow); + const maxTokens = positiveInteger(model.maxTokens); + metadataBySlug.set(slug, { + slug, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + inputModalities: stringList(model.input), + reasoningEfforts, + }); + models.push({ + slug, + name: trimmedString(model.name) || slug, + ...(provider.length > 0 ? { subProvider: titleCaseSlug(provider) } : {}), + isCustom: false, + capabilities: buildOmpModelCapabilities({ + reasoningEfforts, + defaultLevel: trimmedString(thinking.defaultLevel) || undefined, + }), + }); + } + return { + models: models.toSorted((left, right) => left.name.localeCompare(right.name)), + metadataBySlug, + }; +} + +function modelEntriesFromFrame(frame: Record): ReadonlyArray { + if (frame.type !== "response" || frame.command !== "get_available_models") { + return []; + } + const models = (frame.data as Record | undefined)?.models; + return Array.isArray(models) ? models : []; +} + +/** + * Read the `get_available_models` response out of an RPC JSONL transcript. + * Frames that are not that response (`ready`, `available_commands_update`, + * `extension_ui_request`) are skipped, so the same transcript can also feed + * the command catalog. + */ +export function decodeOmpModelCatalog(stdout: string): OmpModelCatalog { + const entries: Array = []; + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + let frame: unknown; + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. + frame = JSON.parse(trimmedLine); + } catch { + continue; + } + if (typeof frame !== "object" || frame === null) continue; + for (const entry of modelEntriesFromFrame(frame as Record)) { + entries.push(entry); + } + } + return catalogFromOmpModelEntries(entries); +} diff --git a/apps/server/src/provider/Drivers/OmpUsage.test.ts b/apps/server/src/provider/Drivers/OmpUsage.test.ts index c9b2ee7abe5a..8dc8e1b2f49f 100644 --- a/apps/server/src/provider/Drivers/OmpUsage.test.ts +++ b/apps/server/src/provider/Drivers/OmpUsage.test.ts @@ -26,6 +26,7 @@ const realisticPayload = JSON.stringify({ { provider: "anthropic", fetchedAt: "2026-09-14T00:00:00.000Z", + metadata: { accountId: "acct-1", email: "dev@example.com" }, limits: [ { id: "5h", @@ -251,10 +252,20 @@ describe("ompUsageToAuth", () => { expect(ompUsageToAuth(decodeOmpUsageOutput(realisticPayload))).toEqual({ status: "authenticated", type: "agent", + email: "dev@example.com", label: "2 providers: anthropic, openai", }); }); + it("omits the account address when omp reports none", () => { + const redacted = JSON.stringify({ reports: [{ provider: "anthropic", limits: [] }] }); + expect(ompUsageToAuth(decodeOmpUsageOutput(redacted))).toEqual({ + status: "authenticated", + type: "agent", + label: "anthropic", + }); + }); + it("stays unknown — never unauthenticated — when the probe cannot tell", () => { expect(ompUsageToAuth(undefined)).toEqual({ status: "unknown" }); expect(ompUsageToAuth(decodeOmpUsageOutput("garbage"))).toEqual({ status: "unknown" }); @@ -300,6 +311,7 @@ describe("probeOmpUsage", () => { expect(result.auth).toEqual({ status: "authenticated", type: "agent", + email: "dev@example.com", label: "2 providers: anthropic, openai", }); expect(result.usageLimits?.windows.map((window) => window.id)).toEqual([ diff --git a/apps/server/src/provider/Drivers/OmpUsage.ts b/apps/server/src/provider/Drivers/OmpUsage.ts index 05e118681214..f32c8fff943e 100644 --- a/apps/server/src/provider/Drivers/OmpUsage.ts +++ b/apps/server/src/provider/Drivers/OmpUsage.ts @@ -77,9 +77,18 @@ const OmpUsageLimitSchema = Schema.Struct({ status: Schema.optional(Schema.String), }); +const OmpUsageAccountMetadataSchema = Schema.Struct({ + accountId: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), + orgName: Schema.optional(Schema.String), +}); + const OmpUsageReportSchema = Schema.Struct({ provider: Schema.optional(Schema.String), fetchedAt: Schema.optional(Schema.String), + // The authenticated account behind this report; omp redacts it only when + // asked, so the plain probe carries the address the card shows. + metadata: Schema.optional(OmpUsageAccountMetadataSchema), // Decoded entry-by-entry below so one malformed limit cannot sink the rest. limits: Schema.optional(Schema.Array(Schema.Unknown)), }); @@ -150,14 +159,21 @@ export function ompUsageProviders(payload: OmpUsagePayload): ReadonlyArray report.metadata?.email?.trim()) + .find((candidate) => candidate !== undefined && candidate.length > 0); return { status: "authenticated", // The single ACP `agent` method backed by local credentials. type: "agent", + ...(email ? { email } : {}), label: providers.length === 1 ? providers[0]! diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts index 3c90678724b4..a282d23ae329 100644 --- a/apps/server/src/provider/Layers/OmpProvider.test.ts +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -52,14 +52,17 @@ function selectDescriptor( }; } -function booleanDescriptor(id: string, label: string, currentValue?: boolean) { - return { - id, - label, - type: "boolean" as const, - ...(typeof currentValue === "boolean" ? { currentValue } : {}), - }; -} +/** + * These fixtures are ACP-only fakes: `--mode rpc` exits without a catalog, so + * `checkOmpProviderStatus` has to reach its ACP fallback to build a model + * list — which is exactly the degraded path the tests below cover. + */ +const RPC_UNSUPPORTED_SOURCE = [ + 'if (process.argv[2] === "--mode") {', + " process.exit(0);", + "}", +].join("\n"); + const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( extraEnv?: Record, ) { @@ -89,6 +92,7 @@ const makeMockAgentWithVersionWrapper = Effect.fn("makeMockAgentWithVersionWrapp name: "fake-omp", env: { T3_ACP_OMP_SHAPES: "1" }, source: [ + RPC_UNSUPPORTED_SOURCE, 'if (process.argv[2] === "--version") {', ' process.stdout.write("omp/18.0.6\\n");', " process.exit(0);", @@ -208,6 +212,7 @@ const makeMockAgentWithUsageWrapper = Effect.fn("makeMockAgentWithUsageWrapper") name: "fake-omp", env: { T3_ACP_OMP_SHAPES: "1" }, source: [ + RPC_UNSUPPORTED_SOURCE, 'if (process.argv[2] === "--version") {', ' process.stdout.write("omp/18.0.6\\n");', " process.exit(0);", @@ -222,6 +227,84 @@ const makeMockAgentWithUsageWrapper = Effect.fn("makeMockAgentWithUsageWrapper") }); }); +/** + * Fake omp with a real RPC catalog: the startup command frame plus a + * `get_available_models` response holding a 1,000,000-token reasoning model + * and a 200,000-token non-reasoning one, shaped like omp/18.1.18. + */ +const makeRpcCatalogOmpWrapper = Effect.fn("makeRpcCatalogOmpWrapper")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-rpc-mock-", + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame for a fake CLI. + const commandFrame = JSON.stringify({ + type: "available_commands_update", + commands: [ + { name: "compact", description: "Compact the context" }, + { name: "model", description: "Show current model selection" }, + { name: "skill:deploy", description: "Deploy the app" }, + ], + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame for a fake CLI. + const modelsResponse = JSON.stringify({ + type: "response", + command: "get_available_models", + data: { + models: [ + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_000_000, + maxTokens: 128_000, + thinking: { + mode: "anthropic-adaptive", + efforts: ["low", "medium", "high", "xhigh", "max"], + }, + }, + { + id: "claude-sonnet-3-5", + name: "Claude Sonnet 3.5", + provider: "anthropic", + reasoning: false, + input: ["text", "image"], + contextWindow: 200_000, + maxTokens: 8192, + }, + ], + }, + }); + return writeFakeCli({ + directory: dir, + name: "fake-omp", + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("omp/18.0.6\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "--mode") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + ` process.stdout.write(${JSON.stringify(`${commandFrame}\n`)});`, + " const chunks = [];", + " for await (const chunk of process.stdin) chunks.push(chunk);", + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + ` if (Buffer.concat(chunks).toString("utf8").includes("get_available_models")) {`, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + ` process.stdout.write(${JSON.stringify(`${modelsResponse}\n`)});`, + " }", + " process.exit(0);", + "}", + ' process.stderr.write("unsupported\\n");', + "process.exit(11);", + "", + ].join("\n"), + }); +}); + const waitForFileContent = Effect.fn("waitForFileContent")(function* ( filePath: string, attempts = 40, @@ -591,7 +674,11 @@ describe("buildOmpCapabilitiesFromConfigOptions", () => { ); }); - it("maps the context_size select onto a contextWindow descriptor", () => { + it("emits no descriptor for options omp never advertised", () => { + // omp/18.1.18 `session/new` advertises exactly mode, model and thinking: + // a `context_size` select or a `fast` toggle would be a control the + // picker offers and omp rejects. Both shapes must stay unmapped even if + // some other agent advertises them. expect( buildOmpCapabilitiesFromConfigOptions([ { @@ -605,22 +692,6 @@ describe("buildOmpCapabilitiesFromConfigOptions", () => { id: "context_size", name: "Context", }, - ]), - ).toEqual( - createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("contextWindow", "Context", [ - { id: "272k", label: "272K" }, - { id: "1m", label: "1M", isDefault: true }, - ]), - ], - }), - ); - }); - - it("maps the boolean fast toggle onto a fastMode descriptor", () => { - expect( - buildOmpCapabilitiesFromConfigOptions([ { type: "boolean", currentValue: true, @@ -629,30 +700,25 @@ describe("buildOmpCapabilitiesFromConfigOptions", () => { name: "Fast", }, ]), - ).toEqual( - createModelCapabilities({ - optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], - }), - ); + ).toEqual(createModelCapabilities({ optionDescriptors: [] })); expect( - buildOmpCapabilitiesFromConfigOptions([ - { - type: "select", - currentValue: "true", - options: [ - { name: "Off", value: "false" }, - { name: "Fast", value: "true" }, - ], - category: "model_config", - id: "fast", - name: "Fast", - }, - ]), - ).toEqual( - createModelCapabilities({ - optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], - }), - ); + resolveOmpAcpConfigUpdates( + [ + { + type: "select", + currentValue: "1m", + options: [{ name: "1M", value: "1m" }], + category: "model_config", + id: "context_size", + name: "Context", + }, + ], + [ + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], + ), + ).toEqual([]); }); }); @@ -676,45 +742,43 @@ describe("checkOmpProviderStatus", () => { }), ); - 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, - }, - ), - ); + effectIt.live("falls back to the ACP catalog when the RPC probe answers no models", () => + Effect.gen(function* () { + const { requestLogPath, wrapperPath } = yield* node(makeProviderStatusEnvFixture()); - 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"); - }), + 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"); + }), ); effectIt.live("reports authenticated usage limits from omp usage --json", () => Effect.gen(function* () { @@ -750,6 +814,74 @@ describe("checkOmpProviderStatus", () => { ]); }), ); + + effectIt.live("sources each model's own context window and ladder from omp", () => + Effect.gen(function* () { + const wrapperPath = yield* node(makeRpcCatalogOmpWrapper()); + + const provider = yield* node( + checkOmpProviderStatus({ + enabled: true, + binaryPath: wrapperPath, + // The duplicate is what a user carries from before omp advertised + // the slug; it must not produce a second picker entry. + customModels: ["anthropic/claude-sonnet-5", "ghost/model"], + }), + ); + + expect(provider.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-3-5", + "anthropic/claude-sonnet-5", + "ghost/model", + ]); + expect(provider.models.filter((model) => model.slug === "anthropic/claude-sonnet-5")).toEqual( + [ + { + slug: "anthropic/claude-sonnet-5", + name: "Claude Sonnet 5", + subProvider: "Anthropic", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Thinking", [ + { id: "off", label: "Off" }, + { id: "auto", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ]), + ], + }), + }, + ], + ); + // The 200,000-window sibling is non-reasoning: no ladder, and its own + // window is the one the meter must divide by, not the 1,000,000 above. + expect( + provider.models.find((model) => model.slug === "anthropic/claude-sonnet-3-5")?.capabilities, + ).toEqual(createModelCapabilities({ optionDescriptors: [] })); + }), + ); + + effectIt.live("publishes omp's own commands and skills at machine level", () => + Effect.gen(function* () { + const wrapperPath = yield* node(makeRpcCatalogOmpWrapper()); + + const provider = yield* node( + checkOmpProviderStatus({ + enabled: true, + binaryPath: wrapperPath, + customModels: [], + }), + ); + + // The Compact affordance reads the base snapshot, not a workspace one. + expect(provider.slashCommands.map((command) => command.name)).toEqual(["compact", "model"]); + expect(provider.skills.map((skill) => skill.name)).toEqual(["deploy"]); + }), + ); }); describe("discoverOmpModelsViaAcp", () => { diff --git a/apps/server/src/provider/Layers/OmpProvider.ts b/apps/server/src/provider/Layers/OmpProvider.ts index 44dff58c0481..bf0a8c1b9fd8 100644 --- a/apps/server/src/provider/Layers/OmpProvider.ts +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -5,6 +5,8 @@ import type { ServerProvider, ServerProviderAuth, ServerProviderModel, + ServerProviderSkill, + ServerProviderSlashCommand, ServerProviderState, ServerProviderUsageLimits, } from "@t3tools/contracts"; @@ -21,14 +23,12 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, - getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, readCustomModelEntries, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { - buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, isCommandMissingCause, @@ -43,6 +43,9 @@ import { } from "../providerMaintenance.ts"; import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { probeOmpUsage } from "../Drivers/OmpUsage.ts"; +import { discoverOmpCommandCatalog, type OmpRpcCatalog } from "../Drivers/OmpCommands.ts"; +import { normalizeOmpReasoningValue, titleCaseSlug } from "../Drivers/OmpModelCatalog.ts"; + const OMP_PRESENTATION = { displayName: "Oh My Pi", badgeLabel: "Early Access", @@ -57,6 +60,10 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const VERSION_PROBE_TIMEOUT_MS = 4_000; const OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +// omp's RPC startup covers config, skills, extensions and MCP, so it is +// slower than a version probe but still has to fail rather than hang: the +// health check waits on it before the ACP fallback gets its own budget. +const OMP_RPC_CATALOG_TIMEOUT_MS = 20_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.", @@ -133,33 +140,6 @@ export function flattenSessionConfigSelectOptions( ); } -function normalizeOmpReasoningValue(value: string | null | undefined): string | undefined { - const normalized = value?.trim().toLowerCase(); - switch (normalized) { - case "off": - case "none": - return "off"; - // omp's own ladder is off|minimal|low|medium|high|xhigh|max|auto; the ACP - // thought_level select advertises a model-dependent subset (e.g. only - // {off, auto} on auto models). Aliases normalize so the picker round-trips - // through resolveOmpAcpConfigUpdates back to the raw advertised value. - case "auto": - return "auto"; - case "minimal": - 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() ?? ""; } @@ -195,53 +175,6 @@ function findOmpEffortConfigOption( ); } -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 { @@ -272,32 +205,12 @@ export function buildOmpCapabilitiesFromConfigOptions( }) : []; - 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 + // omp's `session/new` advertises exactly `mode`, `model` and `thinking` + // (verified on omp/18.1.18 for an adaptive, a `requiresEffort` and a + // non-reasoning model), so there is nothing else to map: a `context_size` + // or `fast` descriptor would offer the picker a control omp rejects. + const optionDescriptors = + reasoningEffortLevels.length > 0 ? [ buildSelectOptionDescriptor({ id: "reasoning", @@ -305,31 +218,7 @@ export function buildOmpCapabilitiesFromConfigOptions( 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, @@ -363,16 +252,6 @@ export function findOmpModelConfigOption( ); } -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 @@ -529,15 +408,6 @@ export function getOmpFallbackModels( ); } -function normalizeOmpConfigOptionToken(value: string | null | undefined): string { - return ( - value - ?.trim() - .toLowerCase() - .replace(/[\s_-]+/g, "-") ?? "" - ); -} - function findOmpSelectOptionValue( configOption: EffectAcpSchema.SessionConfigOption | undefined, matcher: (option: OmpSessionSelectOption) => boolean, @@ -545,22 +415,6 @@ function findOmpSelectOptionValue( 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, @@ -592,35 +446,6 @@ export function resolveOmpAcpConfigUpdates( } } - 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; } @@ -651,6 +476,15 @@ export function buildOmpProviderSnapshot(input: { readonly message?: string; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + /** + * The machine-level catalogs. Per-workspace snapshots stay the + * project-scoped truth (a project can add skills and commands), but the + * base snapshot is what `providerSupportsManualCompaction` reads to decide + * whether to offer Compact, so it must carry omp's own `/compact` rather + * than an empty list. + */ + readonly skills?: ReadonlyArray; + readonly slashCommands?: ReadonlyArray; readonly auth?: ServerProviderAuth; readonly usageLimits?: ServerProviderUsageLimits; }): ServerProviderDraft { @@ -673,6 +507,8 @@ export function buildOmpProviderSnapshot(input: { ), input.ompSettings.customModels, ), + ...(input.skills ? { skills: input.skills } : {}), + ...(input.slashCommands ? { slashCommands: input.slashCommands } : {}), probe: { installed: true, version: input.version, @@ -794,46 +630,73 @@ export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(functi }); } - let discoveredModels = Option.none>(); - let discoveryWarning: string | undefined; - // The usage probe is read-only and bounded by its own timeout, so it rides - // alongside ACP discovery instead of stretching the health check. It never - // fails: every failure mode degrades to unknown auth with no limits. - const [discoveryExit, usageProbe] = yield* Effect.all( + // One RPC probe answers both catalogs: omp's own model metadata (real + // context windows and per-model reasoning ladders, which an ACP `model` + // select cannot express) and the command/skill catalog the composer needs + // at machine level. The ACP session stays the fallback. The usage probe is + // read-only and bounded by its own timeout, so it rides alongside instead + // of stretching the health check; it never fails: every failure mode + // degrades to unknown auth with no limits. + const [rpcExit, usageProbe] = yield* Effect.all( [ Effect.exit( - discoverOmpModelsViaAcp(ompSettings, environment).pipe( - Effect.timeoutOption(OMP_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + discoverOmpCommandCatalog(ompSettings, environment ?? process.env).pipe( + Effect.timeoutOption(OMP_RPC_CATALOG_TIMEOUT_MS), ), ), probeOmpUsage(ompSettings, checkedAt, environment), ], { concurrency: 2 }, ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Oh My Pi ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), + let rpcCatalog: OmpRpcCatalog | undefined; + if (Exit.isFailure(rpcExit)) { + yield* Effect.logWarning("Oh My Pi RPC catalog probe failed", { + errorTag: causeErrorTag(rpcExit.cause), + }); + } else if (Option.isNone(rpcExit.value)) { + yield* Effect.logWarning("Oh My Pi RPC catalog probe timed out", { + timeoutMs: OMP_RPC_CATALOG_TIMEOUT_MS, }); - 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; + rpcCatalog = rpcExit.value.value; + } + + let discoveredModels = rpcCatalog?.models.models ?? []; + let discoveryWarning: string | undefined; + if (discoveredModels.length === 0) { + // No metadata catalog: fall back to the ACP `model` select. Those entries + // carry no context window and only the probe session's model reports + // capabilities, so this is a degraded catalog, not an equivalent one. + 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.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); + const upstreamCount = countOmpUpstreamProviders(discoveredModels); return buildOmpProviderSnapshot({ checkedAt, ompSettings, version, - discoveredModels: resolvedModels, + discoveredModels, + ...(rpcCatalog && rpcCatalog.skills.length > 0 ? { skills: rpcCatalog.skills } : {}), + ...(rpcCatalog && rpcCatalog.slashCommands.length > 0 + ? { slashCommands: rpcCatalog.slashCommands } + : {}), ...(upstreamCount > 0 ? { message: `${upstreamCount} upstream provider${upstreamCount === 1 ? "" : "s"} configured through Oh My Pi.`, @@ -850,8 +713,8 @@ export const checkOmpProviderStatus = Effect.fn("checkOmpProviderStatus")(functi * * 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. + * model or capability discovery. Oh My Pi model data comes from the RPC + * catalog probe (or its ACP fallback) during provider status checks. */ export const enrichOmpSnapshot = (input: { readonly settings: OmpSettings; From 3094fb3496fdfc22d7569974058a6554fcc6a7ed Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 13:02:00 -0300 Subject: [PATCH 10/22] fix(omp): decode omp usage epoch-millis timestamps --- .../src/provider/Drivers/OmpUsage.test.ts | 44 +++++++++++++++++++ apps/server/src/provider/Drivers/OmpUsage.ts | 22 ++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/OmpUsage.test.ts b/apps/server/src/provider/Drivers/OmpUsage.test.ts index 8dc8e1b2f49f..b28f9c0774eb 100644 --- a/apps/server/src/provider/Drivers/OmpUsage.test.ts +++ b/apps/server/src/provider/Drivers/OmpUsage.test.ts @@ -247,6 +247,50 @@ describe("selectOmpBannerWindow", () => { }); }); +describe("epoch-millis timestamps", () => { + // omp 18.1.18 emits `generatedAt`, `fetchedAt` and `resetsAt` as epoch + // milliseconds. Decoding them as strings failed the whole payload and + // reported unknown auth with no limits against a real install. + const epochPayload = JSON.stringify({ + generatedAt: 1_789_401_597_111, + reports: [ + { + provider: "anthropic", + fetchedAt: 1_789_401_435_347, + metadata: { accountId: "acct-1", email: "dev@example.com" }, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h", shared: true }, + window: { + id: "5h", + label: "5 Hour", + durationMs: 18_000_000, + resetsAt: 4_102_444_800_000, + }, + amount: { used: 29, limit: 100, usedFraction: 0.29, unit: "percent" }, + status: "ok", + }, + ], + }, + ], + }); + + it("decodes numeric timestamps into auth and live windows", () => { + const payload = decodeOmpUsageOutput(epochPayload); + expect(ompUsageToAuth(payload)).toEqual({ + status: "authenticated", + type: "agent", + email: "dev@example.com", + label: "anthropic", + }); + const limits = ompUsageToLimits({ payload, checkedAt }); + expect(limits?.windows.map((window) => window.id)).toEqual(["anthropic:5h"]); + expect(limits?.windows[0]?.resetsAt).toBe("2100-01-01T00:00:00.000Z"); + }); +}); + describe("ompUsageToAuth", () => { it("reports authenticated with the provider list when accounts exist", () => { expect(ompUsageToAuth(decodeOmpUsageOutput(realisticPayload))).toEqual({ diff --git a/apps/server/src/provider/Drivers/OmpUsage.ts b/apps/server/src/provider/Drivers/OmpUsage.ts index f32c8fff943e..224bc87704bd 100644 --- a/apps/server/src/provider/Drivers/OmpUsage.ts +++ b/apps/server/src/provider/Drivers/OmpUsage.ts @@ -55,11 +55,17 @@ const OmpUsageAmountSchema = Schema.Struct({ unit: Schema.optional(Schema.String), }); +// omp emits timestamps as epoch milliseconds (`"generatedAt": 1789401597111`), +// not ISO strings, and a single wrong type would fail the whole payload decode +// and silently report unauthenticated-but-unknown. Both shapes are accepted +// and normalized where they are read. +const OmpUsageTimestampSchema = Schema.Union([Schema.String, Schema.Number]); + const OmpUsageWindowSchema = Schema.Struct({ id: Schema.optional(Schema.String), label: Schema.optional(Schema.String), durationMs: Schema.optional(Schema.Number), - resetsAt: Schema.optional(Schema.String), + resetsAt: Schema.optional(OmpUsageTimestampSchema), }); const OmpUsageScopeSchema = Schema.Struct({ @@ -85,7 +91,7 @@ const OmpUsageAccountMetadataSchema = Schema.Struct({ const OmpUsageReportSchema = Schema.Struct({ provider: Schema.optional(Schema.String), - fetchedAt: Schema.optional(Schema.String), + fetchedAt: Schema.optional(OmpUsageTimestampSchema), // The authenticated account behind this report; omp redacts it only when // asked, so the plain probe carries the address the card shows. metadata: Schema.optional(OmpUsageAccountMetadataSchema), @@ -94,7 +100,7 @@ const OmpUsageReportSchema = Schema.Struct({ }); const OmpUsagePayloadSchema = Schema.Struct({ - generatedAt: Schema.optional(Schema.String), + generatedAt: Schema.optional(OmpUsageTimestampSchema), // Same per-entry tolerance as limits: a bad report is skipped, not fatal. reports: Schema.optional(Schema.Array(Schema.Unknown)), }); @@ -253,6 +259,14 @@ function parseIsoDate(value: string | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +/** omp timestamps arrive as epoch milliseconds or, in older builds, ISO text. */ +function ompTimestampToMillis(value: string | number | undefined): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + return parseIsoDate(value?.trim()); +} + // Effect represents dates through `DateTime`; this mirrors the Codex usage // mapping, which builds its reset timestamps the same way. function isoFromMillis(value: number): string | undefined { @@ -276,7 +290,7 @@ function ompUsageLimitToWindow(input: { if (usedPercent === undefined) { return undefined; } - const resetsAtMs = parseIsoDate(limit.window?.resetsAt?.trim()); + const resetsAtMs = ompTimestampToMillis(limit.window?.resetsAt); if (resetsAtMs !== undefined && checkedAtMs !== undefined && resetsAtMs <= checkedAtMs) { return undefined; } From 94a92c85294054099e1925ef05d45dfa2fd9381b Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 13:15:03 -0300 Subject: [PATCH 11/22] fix(omp): mark the active model as catalog default so composer controls resolve --- .../src/provider/Drivers/OmpCommands.test.ts | 46 +++++++++++++------ .../src/provider/Drivers/OmpCommands.ts | 5 ++ .../src/provider/Drivers/OmpModelCatalog.ts | 37 +++++++++++++-- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts index 90b3f8451089..624bf801077c 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.test.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -194,20 +194,35 @@ const fakeRpcOmpSource = (spawnLogPath: string) => )});`, "const chunks = [];", "for await (const chunk of process.stdin) chunks.push(chunk);", - 'const request = JSON.parse(Buffer.concat(chunks).toString("utf8").trim());', - 'if (request.type === "get_available_models") {', - " process.stdout.write(", - " JSON.stringify({", - " id: request.id,", - ' type: "response",', - ' command: "get_available_models",', - " data: {", - " models: [", - ' { id: "claude-sonnet-5", name: "Claude Sonnet 5", provider: "anthropic", reasoning: true, contextWindow: 1000000, thinking: { mode: "anthropic-adaptive", efforts: ["low", "high"] } },', - " ],", - " },", - ' }) + "\\n",', - " );", + 'const requests = Buffer.concat(chunks).toString("utf8").trim().split("\\n")', + " .filter((line) => line.trim().length > 0)", + " .map((line) => JSON.parse(line));", + "for (const request of requests) {", + ' if (request.type === "get_available_models") {', + " process.stdout.write(", + " JSON.stringify({", + " id: request.id,", + ' type: "response",', + ' command: "get_available_models",', + " data: {", + " models: [", + ' { id: "claude-sonnet-5", name: "Claude Sonnet 5", provider: "anthropic", reasoning: true, contextWindow: 1000000, thinking: { mode: "anthropic-adaptive", efforts: ["low", "high"] } },', + ' { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic", reasoning: false, contextWindow: 200000 },', + " ],", + " },", + ' }) + "\\n",', + " );", + " }", + ' if (request.type === "get_state") {', + " process.stdout.write(", + " JSON.stringify({", + " id: request.id,", + ' type: "response",', + ' command: "get_state",', + ' data: { model: { provider: "anthropic", id: "claude-haiku-4-5" } },', + ' }) + "\\n",', + " );", + " }", "}", "process.exit(0);", "", @@ -236,6 +251,9 @@ describe("discoverOmpCommandCatalog", () => { expect(catalog.models.metadataBySlug.get("anthropic/claude-sonnet-5")?.contextWindow).toBe( 1_000_000, ); + expect( + catalog.models.models.filter((model) => model.isDefault === true).map((m) => m.slug), + ).toEqual(["anthropic/claude-haiku-4-5"]); const spawnLog = yield* fileSystem.readFileString(spawnLogPath); expect(spawnLog.trim().split("\n")).toHaveLength(1); }).pipe(Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/provider/Drivers/OmpCommands.ts b/apps/server/src/provider/Drivers/OmpCommands.ts index 4d2e64812691..b8f31dae4589 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -40,6 +40,7 @@ import { collectStreamAsString } from "../providerSnapshot.ts"; import { decodeOmpModelCatalog, OMP_AVAILABLE_MODELS_REQUEST_ID, + OMP_STATE_REQUEST_ID, type OmpModelCatalog, } from "./OmpModelCatalog.ts"; @@ -217,6 +218,10 @@ export const discoverOmpCommandCatalog = Effect.fn("discoverOmpCommandCatalog")( id: OMP_AVAILABLE_MODELS_REQUEST_ID, type: "get_available_models", })}\n`, + // The active model marks the catalog's default; without one the + // client cannot resolve a model for a fresh thread. + // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. + `${JSON.stringify({ id: OMP_STATE_REQUEST_ID, type: "get_state" })}\n`, ), ), child.stdin, diff --git a/apps/server/src/provider/Drivers/OmpModelCatalog.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.ts index 4c5c381e6769..39c3e4051f96 100644 --- a/apps/server/src/provider/Drivers/OmpModelCatalog.ts +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.ts @@ -38,6 +38,9 @@ import { buildSelectOptionDescriptor } from "../providerSnapshot.ts"; /** The single RPC request that answers the whole catalog. */ export const OMP_AVAILABLE_MODELS_REQUEST_ID = "t3-model-catalog"; +/** The follow-up request that names omp's active model. */ +export const OMP_STATE_REQUEST_ID = "t3-model-state"; + /** * omp's thinking ladder, in ascending order, with the picker labels T3 uses. * `off`/`auto` are session-level values omp adds to every model's select; @@ -207,7 +210,10 @@ export function buildOmpModelCapabilities(input: { * slug (`/`) and sorted by display name: the catalog is 121 * entries deep on a default install. */ -export function catalogFromOmpModelEntries(entries: ReadonlyArray): OmpModelCatalog { +export function catalogFromOmpModelEntries( + entries: ReadonlyArray, + activeSlug?: string | undefined, +): OmpModelCatalog { const models: Array = []; const metadataBySlug = new Map(); for (const entry of entries) { @@ -236,6 +242,10 @@ export function catalogFromOmpModelEntries(entries: ReadonlyArray): Omp name: trimmedString(model.name) || slug, ...(provider.length > 0 ? { subProvider: titleCaseSlug(provider) } : {}), isCustom: false, + // Without a default the client cannot resolve a model for a fresh + // thread, and an unresolved model takes the composer's whole traits + // control with it — the thinking ladder included. + ...(activeSlug !== undefined && slug === activeSlug ? { isDefault: true } : {}), capabilities: buildOmpModelCapabilities({ reasoningEfforts, defaultLevel: trimmedString(thinking.defaultLevel) || undefined, @@ -256,6 +266,24 @@ function modelEntriesFromFrame(frame: Record): ReadonlyArray/`. + */ +function activeSlugFromFrame(frame: Record): string | undefined { + if (frame.type !== "response" || frame.command !== "get_state") { + return undefined; + } + const model = (frame.data as Record | undefined)?.model; + if (typeof model !== "object" || model === null) return undefined; + const record = model as Record; + const id = trimmedString(record.id); + if (id.length === 0) return undefined; + const provider = trimmedString(record.provider); + return provider.length > 0 ? `${provider}/${id}` : id; +} + /** * Read the `get_available_models` response out of an RPC JSONL transcript. * Frames that are not that response (`ready`, `available_commands_update`, @@ -264,6 +292,7 @@ function modelEntriesFromFrame(frame: Record): ReadonlyArray = []; + let activeSlug: string | undefined; for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) continue; @@ -275,9 +304,11 @@ export function decodeOmpModelCatalog(stdout: string): OmpModelCatalog { continue; } if (typeof frame !== "object" || frame === null) continue; - for (const entry of modelEntriesFromFrame(frame as Record)) { + const record = frame as Record; + activeSlug = activeSlugFromFrame(record) ?? activeSlug; + for (const entry of modelEntriesFromFrame(record)) { entries.push(entry); } } - return catalogFromOmpModelEntries(entries); + return catalogFromOmpModelEntries(entries, activeSlug); } From 3e4e7a74cbb4622a1156ab9c51731ab841bd4cc9 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 13:38:58 -0300 Subject: [PATCH 12/22] feat(omp): offer Oh My Pi in onboarding agents and project import copy --- .../components/onboarding/WelcomeWizard.tsx | 6 ++-- .../providerReadiness.logic.test.ts | 33 +++++++++++++++++++ .../src/onboarding/providerReadiness.logic.ts | 21 +++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 07744c702e15..6811f92a1679 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -606,7 +606,7 @@ function PairingForm({ // ── Step 3: agents ─────────────────────────────────────────── -const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex", "omp"] as const; type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; /** Setup values stay fixed while provider probes refresh the surrounding cards. */ @@ -1164,7 +1164,7 @@ function ImportStep({

- Looking for projects from Claude Code and Codex… + Looking for projects from Claude Code, Codex and Oh My Pi…

@@ -1244,7 +1244,7 @@ function ImportStep({
) : scanCandidates.length === 0 ? (

- No existing Claude Code or Codex projects found. + No existing Claude Code, Codex or Oh My Pi projects found.

) : null} {scan.data?.truncated ? ( diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index b0b3a4d57515..a3539c061b88 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -190,6 +190,30 @@ describe("resolveOnboardingProviderLoginCommand", () => { ).toBe("/opt/claude-work/bin/claude auth login"); }); + it("opens omp's own setup flow, since it has no login subcommand", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("omp"), + instanceId: ProviderInstanceId.make("omp"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/omp/bin/omp" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/omp/bin/omp setup"); + }); + it("quotes a Codex path with spaces for PowerShell", () => { expect( resolveOnboardingProviderLoginCommand( @@ -335,4 +359,13 @@ describe("resolveOnboardingProviderInstallCommand", () => { "curl -fsSL https://claude.ai/install.sh | bash", ); }); + + it("uses omp's own installer, which its self-updater keeps current", () => { + expect(resolveOnboardingProviderInstallCommand("omp", "windows")).toBe( + "irm https://omp.sh/install.ps1 | iex", + ); + expect(resolveOnboardingProviderInstallCommand("omp", "linux")).toBe( + "curl -fsSL https://omp.sh/install | sh", + ); + }); }); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index c9d0f910ef53..ce90ae58aec6 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -2,6 +2,7 @@ import { ClaudeSettings, CodexSettings, type ExecutionEnvironmentPlatformOs, + OmpSettings, type ServerProvider, type ServerSettings, } from "@t3tools/contracts"; @@ -10,6 +11,7 @@ import * as Schema from "effect/Schema"; const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeOmpSettings = Schema.decodeUnknownOption(OmpSettings); const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; function quoteProviderBinary( @@ -72,7 +74,7 @@ export function selectOnboardingProvidersByDriver( } /** - * Official standalone installers. Neither needs Node or npm, and both land in + * Official standalone installers. None needs Node or npm, and all land in * the paths the server's provider maintenance recognizes as native, so the * one-click updater in Settings keeps working after install. */ @@ -85,6 +87,12 @@ const NATIVE_INSTALL_COMMANDS = { windows: "irm https://chatgpt.com/codex/install.ps1 | iex", posix: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", }, + // omp ships its own installer and updater; `omp update` keeps it current + // afterwards, which is exactly what its maintenance resolver advertises. + omp: { + windows: "irm https://omp.sh/install.ps1 | iex", + posix: "curl -fsSL https://omp.sh/install | sh", + }, } as const; /** @@ -125,5 +133,16 @@ export function resolveOnboardingProviderLoginCommand( return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; } + if (provider.driver === "omp") { + const config = decodeOmpSettings( + instance ? (instance.config ?? {}) : (settings.providers.omp ?? {}), + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "omp"; + // omp has no `login` subcommand: `omp setup` runs the onboarding that + // authenticates providers, and it is interactive, which is what the + // inline terminal is for. + return `${quoteProviderBinary(binaryPath, "omp", platform)} setup`; + } + return provider.driver; } From 06be10e6d17434f016666efa948d4286a25611b0 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 13:40:10 -0300 Subject: [PATCH 13/22] docs(omp): note onboarding install, sign-in and project import --- docs/user/providers-oh-my-pi.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/user/providers-oh-my-pi.md b/docs/user/providers-oh-my-pi.md index 25ce6156d69d..49b139d1e10a 100644 --- a/docs/user/providers-oh-my-pi.md +++ b/docs/user/providers-oh-my-pi.md @@ -17,6 +17,11 @@ set **Binary path** on the card. When omp is behind its latest release, the card offers **Update now**, which runs omp's own updater (`omp update`). No other installer is involved. +**Setup** (the first-run wizard) lists Oh My Pi next to Claude Code and Codex, +with the same inline terminal: **Install** pre-types omp's own installer, and +**Sign in** pre-types `omp setup`. Its **Projects** step also offers the +directories omp ran in, with their conversations. + ## What carries over from the terminal | Terminal feature | In T3 Code | From 2132479bff3a0973869c4d4de1c33df3f37be91b Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 14:34:46 -0300 Subject: [PATCH 14/22] fix(omp): strip terminal escapes from message text; drop unused fork/list surface --- .../src/provider/Layers/OmpAdapter.test.ts | 94 +---------- apps/server/src/provider/Layers/OmpAdapter.ts | 151 ++++-------------- .../src/provider/Services/OmpAdapter.ts | 62 +------ .../src/provider/acp/OmpAcpSupport.test.ts | 80 ---------- apps/server/src/provider/acp/OmpAcpSupport.ts | 89 ----------- apps/server/src/provider/acp/OmpAnsi.test.ts | 49 ++++++ apps/server/src/provider/acp/OmpAnsi.ts | 58 +++++++ 7 files changed, 145 insertions(+), 438 deletions(-) create mode 100644 apps/server/src/provider/acp/OmpAnsi.test.ts create mode 100644 apps/server/src/provider/acp/OmpAnsi.ts diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 498762026b94..0c6e06a1ce4e 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -2728,111 +2728,25 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { }), ); - it.effect("forks the live session into a new omp session and leaves it usable", () => + // The migrating-terminal-user path: the session id comes from the imported + // transcript on disk, and `session/load` has to reopen exactly it. + it.effect("resumes an omp session named by an imported cursor", () => Effect.gen(function* () { const adapter = yield* OmpAdapter; const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("omp-fork-thread"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper({ T3_ACP_OMP_SESSION_STORE: "1" }), ); yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); - const session = yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("omp"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const cursor = session.resumeCursor; - assert.isTrue( - typeof cursor === "object" && cursor !== null && "sessionId" in cursor, - "startSession must persist an omp resume cursor", - ); - const originalSessionId = - typeof cursor === "object" && cursor !== null && "sessionId" in cursor - ? String(cursor.sessionId) - : ""; - - const fork = yield* adapter.forkSession(threadId); - assert.notEqual(fork.sessionId, originalSessionId); - assert.equal(fork.sessionId, `${originalSessionId}-fork-1`); - assert.deepStrictEqual(fork.resumeCursor, { schemaVersion: 1, sessionId: fork.sessionId }); - assert.equal(fork.cwd, session.cwd); - - // The fork is a copy: the original thread keeps running on its own - // session id and can still take a turn. - const turn = yield* adapter.sendTurn({ threadId, input: "still alive", attachments: [] }); - assert.equal(String(turn.threadId), String(threadId)); - assert.deepStrictEqual( - (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId)?.resumeCursor, - { schemaVersion: 1, sessionId: originalSessionId }, - ); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("refuses to fork a thread with no live omp session", () => - Effect.gen(function* () { - const adapter = yield* OmpAdapter; - const exit = yield* Effect.exit(adapter.forkSession(ThreadId.make("omp-fork-missing"))); - assert.isTrue(Exit.isFailure(exit)); - }), - ); - - // The migrating-terminal-user path: no session is open here, so discovery - // has to stand up its own connection. - it.effect("discovers omp's own sessions without a live session and resumes one", () => - Effect.gen(function* () { - const adapter = yield* OmpAdapter; - const settings = yield* ServerSettingsService; - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ - T3_ACP_OMP_SESSION_STORE: "1", - T3_ACP_SESSION_LIST_CWD: process.cwd(), - }), - ); - yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); - - const page = yield* adapter.listNativeSessions({ cwd: process.cwd() }); - assert.equal(page.nextCursor, "mock-cursor-2"); - assert.equal(page.skippedCount, 0); - assert.deepStrictEqual( - page.sessions.map((entry) => entry.sessionId), - ["omp-session-terminal-1", "omp-session-elsewhere-1"], - ); - const terminalSession = page.sessions[0]; - assert.isDefined(terminalSession); - assert.equal(terminalSession?.title, "Terminal session"); - assert.equal(terminalSession?.updatedAt, "2026-02-03T04:05:06.000Z"); - assert.equal(terminalSession?.cwd, process.cwd()); - assert.equal(terminalSession?.messageCount, 12); - assert.deepStrictEqual(terminalSession?.resumeCursor, { - schemaVersion: 1, - sessionId: "omp-session-terminal-1", - }); - - // A session from another workspace is still listed; the filter is the - // caller's choice, not an implicit one. - const filtered = yield* adapter.listNativeSessions({ - cwd: process.cwd(), - filterCwd: process.cwd(), - }); - assert.deepStrictEqual( - filtered.sessions.map((entry) => entry.sessionId), - ["omp-session-terminal-1"], - ); - const threadId = ThreadId.make("omp-resume-discovered-thread"); const resumed = yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("omp"), cwd: process.cwd(), runtimeMode: "full-access", - resumeCursor: terminalSession?.resumeCursor, + resumeCursor: { schemaVersion: 1, sessionId: "omp-session-terminal-1" }, }); assert.deepStrictEqual(resumed.resumeCursor, { schemaVersion: 1, diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index db8a3330fb7f..c767eb9f1372 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -76,10 +76,9 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyOmpAcpModelSelection, makeOmpAcpRuntime, - parseOmpForkedSessionId, - parseOmpSessionList, resolveOmpAcpBaseModelId, } from "../acp/OmpAcpSupport.ts"; +import { type AnsiFilter, makeAnsiFilter } from "../acp/OmpAnsi.ts"; import { type OmpAdapterShape } from "../Services/OmpAdapter.ts"; import { rewriteOmpSkillMentions } from "../Drivers/OmpSkillDispatch.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -174,6 +173,8 @@ interface OmpSessionContext { * into the same turn does not repeat the warning. */ modelWarningTurnId: TurnId | undefined; lastPlanFingerprint: string | undefined; + /** Strips omp's terminal escapes out of streamed message text. */ + readonly ansiFilter: AnsiFilter; activeTurnId: TurnId | undefined; /** Context occupancy last reported by omp's `usage_update`. The * `session/prompt` response only carries per-turn tokens, so the context @@ -1523,6 +1524,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ompSubagentActivity: new Map(), turns: [], lastPlanFingerprint: undefined, + ansiFilter: makeAnsiFilter(), activeTurnId: undefined, modelWarningTurnId: undefined, lastContextUsedTokens: undefined, @@ -1553,7 +1555,24 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv }), ); return; - case "AssistantItemCompleted": + case "AssistantItemCompleted": { + // A chunk boundary can split an escape sequence, so the + // stripper holds a partial tail back: release whatever + // turned out to be real text before closing the item. + const tail = ctx.ansiFilter.flush(); + if (tail.length > 0) { + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + text: tail, + rawPayload: undefined, + }), + ); + } yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ stamp: yield* makeEventStamp(), @@ -1565,6 +1584,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv }), ); return; + } case "PlanUpdated": yield* logNative(ctx.threadId, "session/update", event.rawPayload); yield* emitPlanUpdate(ctx, event.payload, event.rawPayload); @@ -1584,8 +1604,15 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv yield* emitOmpSubagentEvents(ctx, event.toolCall); return; case "ThoughtDelta": - case "ContentDelta": + case "ContentDelta": { yield* logNative(ctx.threadId, "session/update", event.rawPayload); + // omp writes terminal output into chat text (`/context` + // draws colored bars), and the escapes would render as + // literal `[38;2;…m` noise. + const text = ctx.ansiFilter.push(event.text); + if (text.length === 0) { + return; + } yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ stamp: yield* makeEventStamp(), @@ -1598,11 +1625,12 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv ...(event._tag === "ThoughtDelta" ? { streamKind: "reasoning_text" as const } : {}), - text: event.text, + text, rawPayload: event.rawPayload, }), ); return; + } } }), ), @@ -2117,117 +2145,6 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv const stopAll: OmpAdapterShape["stopAll"] = () => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); - const forkSession: OmpAdapterShape["forkSession"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const sessionId = parseOmpResume(ctx.session.resumeCursor)?.sessionId; - const cwd = ctx.session.cwd; - // `cwd` is required: omitting it fails inside omp with a bare - // "path must be of type string" internal error (verified on 18.1.x). - if (sessionId === undefined || cwd === undefined) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/fork", - detail: "The Oh My Pi session has no native session id and cwd to fork.", - }); - } - const response = yield* ctx.acp - .request("session/fork", { sessionId, cwd }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/fork", error), - ), - ); - const forkedSessionId = parseOmpForkedSessionId(response); - if (forkedSessionId === undefined) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/fork", - detail: "Oh My Pi returned no session id for the forked session.", - }); - } - return { - sessionId: forkedSessionId, - cwd, - resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId: forkedSessionId }, - }; - }); - - /** - * Runs one `session/list` request on a dedicated, scoped `omp acp` - * child that is only initialized — no session is created, so discovery - * never shows up in the very list it is reading. - * - * A live session's connection is deliberately NOT reused: which - * sessions exist is a property of the configured binary and profile, - * not of whichever thread happens to be open, and an open session may - * have been spawned from an older settings snapshot. - */ - const requestSessionList = (input: { - readonly cwd: string; - readonly payload: Record; - }) => - Effect.gen(function* () { - const effectiveOmpSettings = options?.resolveSettings - ? yield* options.resolveSettings - : ompSettings; - return yield* Effect.scoped( - Effect.gen(function* () { - const acp = yield* makeOmpAcpRuntime({ - ompSettings: effectiveOmpSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd: input.cwd, - clientInfo: { name: "t3-code", version: "0.0.0" }, - // Nothing answers elicitation on a discovery connection. - enableElicitation: false, - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); - yield* acp.initialize(); - return yield* acp.request("session/list", input.payload); - }), - ); - }); - - const listNativeSessions: OmpAdapterShape["listNativeSessions"] = (input) => - Effect.gen(function* () { - const cwd = input.cwd.trim(); - if (!cwd) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "listNativeSessions", - issue: "cwd is required and must be non-empty.", - }); - } - const filterCwd = input.filterCwd?.trim(); - const cursor = input.cursor?.trim(); - const response = yield* requestSessionList({ - cwd: path.resolve(cwd), - payload: { - ...(filterCwd ? { cwd: path.resolve(filterCwd) } : {}), - ...(cursor ? { cursor } : {}), - }, - }).pipe( - Effect.mapError( - (error) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/list", - detail: error.message, - cause: error, - }), - ), - ); - const listing = parseOmpSessionList(response); - return { - sessions: listing.sessions.map((session) => ({ - ...session, - resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId: session.sessionId }, - })), - ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}), - skippedCount: listing.skippedCount, - }; - }); - yield* Effect.addFinalizer(() => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( Effect.catch((cause) => @@ -2261,8 +2178,6 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv respondToUserInput, stopSession, listSessions, - forkSession, - listNativeSessions, hasSession, stopAll, streamEvents, diff --git a/apps/server/src/provider/Services/OmpAdapter.ts b/apps/server/src/provider/Services/OmpAdapter.ts index df3ca01523bf..847d3b4f53ea 100644 --- a/apps/server/src/provider/Services/OmpAdapter.ts +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -9,10 +9,6 @@ * * @module OmpAdapter */ -import type { ThreadId } from "@t3tools/contracts"; -import type * as Effect from "effect/Effect"; - -import type { OmpNativeSessionInfo } from "../acp/OmpAcpSupport.ts"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; @@ -27,64 +23,8 @@ export interface OmpResumeCursor { readonly sessionId: string; } -/** An omp session discovered through `session/list`, ready to resume. */ -export interface OmpDiscoveredSession extends OmpNativeSessionInfo { - readonly resumeCursor: OmpResumeCursor; -} - -export interface OmpDiscoveredSessionPage { - readonly sessions: ReadonlyArray; - readonly nextCursor?: string; - /** Entries omp returned that carried no usable session id or cwd. */ - readonly skippedCount: number; -} - -export interface OmpListNativeSessionsInput { - /** - * Directory the discovery connection runs in. omp's session store is - * global, so this only decides where the short-lived `omp acp` child is - * spawned, not which sessions come back. - */ - readonly cwd: string; - /** Restricts results to one workspace. Omitted lists every omp session. */ - readonly filterCwd?: string; - /** Opaque cursor from a previous page's `nextCursor`. */ - readonly cursor?: string; -} - -/** A live omp session copied at its current state by `session/fork`. */ -export interface OmpForkedSession { - readonly sessionId: string; - readonly cwd: string; - readonly resumeCursor: OmpResumeCursor; -} - /** * OmpAdapterShape — per-instance Oh My Pi adapter contract. Carries * a branded driver kind as the nominal discriminant. - * - * Extends the shared adapter contract with the two session-store - * operations omp exposes over ACP and the other providers do not: - * forking a live session and enumerating the agent's own sessions. */ -export interface OmpAdapterShape extends ProviderAdapterShape { - /** - * Copies a live session at its current state through ACP `session/fork` - * and returns a cursor the caller can start a sibling thread from. The - * original session keeps running and is not rewound — ACP has no rewind - * (see `capabilities.supportsConversationRollback`). - */ - readonly forkSession: ( - threadId: ThreadId, - ) => Effect.Effect; - - /** - * Enumerates the sessions omp itself persisted — including the ones a - * user started in the terminal — so they can be resumed here. Served by - * a live session's connection when one exists, otherwise by a - * short-lived `omp acp` child that only initializes. - */ - readonly listNativeSessions: ( - input: OmpListNativeSessionsInput, - ) => Effect.Effect; -} +export type OmpAdapterShape = ProviderAdapterShape; diff --git a/apps/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts index c80ab9b90e4a..86ee94ab78e9 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.test.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -7,8 +7,6 @@ import { applyOmpAcpModelSelection, buildOmpAcpSpawnInput, ompAcpSpawnArgs, - parseOmpForkedSessionId, - parseOmpSessionList, resolveOmpAcpBaseModelId, } from "./OmpAcpSupport.ts"; @@ -311,81 +309,3 @@ describe("applyOmpAcpModelSelection", () => { }), ); }); - -describe("parseOmpSessionList", () => { - it("keeps cwd, title, updatedAt and omp's transcript stats", () => { - const listing = parseOmpSessionList({ - sessions: [ - { - sessionId: "omp-1", - cwd: "C:/work/repo", - title: "Terminal session", - updatedAt: "2026-02-03T04:05:06.000Z", - _meta: { messageCount: 12, size: 8192 }, - }, - ], - nextCursor: "cursor-2", - }); - - expect(listing).toEqual({ - sessions: [ - { - sessionId: "omp-1", - cwd: "C:/work/repo", - title: "Terminal session", - updatedAt: "2026-02-03T04:05:06.000Z", - messageCount: 12, - sizeBytes: 8192, - }, - ], - nextCursor: "cursor-2", - skippedCount: 0, - }); - }); - - it("skips unusable entries instead of failing the whole page", () => { - const listing = parseOmpSessionList({ - sessions: [ - { sessionId: "omp-ok", cwd: "/work" }, - { sessionId: 42, cwd: "/work" }, - { sessionId: "omp-no-cwd" }, - { cwd: "/work" }, - null, - "nonsense", - ], - }); - - expect(listing.sessions).toEqual([{ sessionId: "omp-ok", cwd: "/work" }]); - expect(listing.skippedCount).toBe(5); - expect(listing.nextCursor).toBeUndefined(); - }); - - it("reads a non-numeric transcript stat as absent rather than as a count", () => { - const listing = parseOmpSessionList({ - sessions: [{ sessionId: "omp-1", cwd: "/work", _meta: { messageCount: "many", size: -1 } }], - }); - - expect(listing.sessions[0]).toEqual({ sessionId: "omp-1", cwd: "/work" }); - }); - - it("returns an empty page for a response without a sessions array", () => { - expect(parseOmpSessionList({ sessions: "none" })).toEqual({ - sessions: [], - skippedCount: 0, - }); - expect(parseOmpSessionList(undefined)).toEqual({ sessions: [], skippedCount: 0 }); - }); -}); - -describe("parseOmpForkedSessionId", () => { - it("reads the forked session id out of a full session setup response", () => { - expect( - parseOmpForkedSessionId({ sessionId: "omp-1-fork-1", configOptions: [], modes: null }), - ).toBe("omp-1-fork-1"); - }); - - it("reports a response without a usable id", () => { - expect(parseOmpForkedSessionId({ configOptions: [] })).toBeUndefined(); - expect(parseOmpForkedSessionId({ sessionId: " " })).toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index c2bde300e0c2..2dce9cf53a00 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -205,92 +205,3 @@ export function resolveOmpAcpBaseModelId(model: string | null | undefined): stri const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")).trim() : trimmed; return base.length > 0 ? base : undefined; } - -/** - * One omp session as reported by ACP `session/list`. omp answers with the - * spec's `SessionInfo` plus its own transcript stats under `_meta` - * (`messageCount`, `size`), which the picker shows so a migrating terminal - * user can tell an empty session from a long one. - */ -export interface OmpNativeSessionInfo { - readonly sessionId: string; - readonly cwd: string; - readonly title?: string; - readonly updatedAt?: string; - readonly messageCount?: number; - readonly sizeBytes?: number; -} - -export interface OmpNativeSessionListing { - readonly sessions: ReadonlyArray; - readonly nextCursor?: string; - /** Entries omp returned without a usable session id or cwd. */ - readonly skippedCount: number; -} - -function listRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function listTrimmedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -/** - * Parses a `session/list` response leniently. A session picker must not go - * blank because one persisted session lost its cwd or carries a field shape - * this build does not know: unusable entries are counted and dropped, every - * readable one is kept. - */ -export function parseOmpSessionList(raw: unknown): OmpNativeSessionListing { - const response = listRecord(raw); - const entries = Array.isArray(response?.sessions) ? response.sessions : []; - const sessions: Array = []; - let skippedCount = 0; - for (const entry of entries) { - const record = listRecord(entry); - const sessionId = listTrimmedString(record?.sessionId); - const cwd = listTrimmedString(record?.cwd); - if (!record || !sessionId || !cwd) { - skippedCount += 1; - continue; - } - const meta = listRecord(record._meta); - const title = listTrimmedString(record.title); - const updatedAt = listTrimmedString(record.updatedAt); - // omp reports transcript stats under `_meta`; anything non-numeric there - // is another agent's metadata and must not reach the picker as a count. - const messageCount = meta?.messageCount; - const sizeBytes = meta?.size; - sessions.push({ - sessionId, - cwd, - ...(title ? { title } : {}), - ...(updatedAt ? { updatedAt } : {}), - ...(typeof messageCount === "number" && Number.isInteger(messageCount) && messageCount >= 0 - ? { messageCount } - : {}), - ...(typeof sizeBytes === "number" && Number.isInteger(sizeBytes) && sizeBytes >= 0 - ? { sizeBytes } - : {}), - }); - } - const nextCursor = listTrimmedString(response?.nextCursor); - return { - sessions, - ...(nextCursor ? { nextCursor } : {}), - skippedCount, - }; -} - -/** - * Reads the new session id out of a `session/fork` response. omp answers - * with a full session setup payload (`configOptions`, `modes`, `models`); - * only the id is load-bearing for the client, the rest is re-read when the - * fork is opened as its own session. - */ -export function parseOmpForkedSessionId(raw: unknown): string | undefined { - return listTrimmedString(listRecord(raw)?.sessionId); -} diff --git a/apps/server/src/provider/acp/OmpAnsi.test.ts b/apps/server/src/provider/acp/OmpAnsi.test.ts new file mode 100644 index 000000000000..cc4ed4e44ad9 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAnsi.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { makeAnsiFilter, stripAnsi } from "./OmpAnsi.ts"; + +/** One line of real `/context` output, as omp writes it into chat text. */ +const CONTEXT_LINE = + "\u001B[38;2;107;114;128m\u2591\u001B[39m\u001B[38;2;156;163;176m\u2591\u001B[39m\u001B[1m\u001B[38;2;0;180;255m\u2591\u001B[22m\u001B[39m 1% 10384 tokens System tools"; + +describe("stripAnsi", () => { + it("keeps the text omp drew and drops the escapes", () => { + expect(stripAnsi(CONTEXT_LINE)).toBe("\u2591\u2591\u2591 1% 10384 tokens System tools"); + }); + + it("drops OSC hyperlinks and two-character escapes", () => { + expect(stripAnsi("\u001B]8;;https://omp.sh\u0007omp\u001B]8;;\u0007\u001B(B done")).toBe( + "omp done", + ); + }); + + it("leaves text without escapes untouched", () => { + expect(stripAnsi("Context window: 1000000 tokens (4% used)")).toBe( + "Context window: 1000000 tokens (4% used)", + ); + }); +}); + +describe("makeAnsiFilter", () => { + it("strips a sequence split across chunk boundaries", () => { + const filter = makeAnsiFilter(); + const chunks = ["bar \u001B[38;2;0", ";180;255m", "\u2591\u001B[39m tail"]; + const output = chunks.map((chunk) => filter.push(chunk)).join("") + filter.flush(); + + expect(output).toBe("bar \u2591 tail"); + }); + + it("never withholds ordinary text", () => { + const filter = makeAnsiFilter(); + + expect(filter.push("plain text")).toBe("plain text"); + expect(filter.flush()).toBe(""); + }); + + it("discards a partial escape that the stream never completed", () => { + const filter = makeAnsiFilter(); + + expect(filter.push("done \u001B[38;2")).toBe("done "); + expect(filter.flush()).toBe(""); + }); +}); diff --git a/apps/server/src/provider/acp/OmpAnsi.ts b/apps/server/src/provider/acp/OmpAnsi.ts new file mode 100644 index 000000000000..5543c93637c6 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAnsi.ts @@ -0,0 +1,58 @@ +/** + * omp renders some command output for a terminal, not for a chat bubble: + * `/context` draws its bars with SGR color codes, and those arrive verbatim + * in `agent_message_chunk` text. T3 Code renders message text as text, so the + * escapes show up as `[38;2;107;114;128m` noise around every bar. + * + * Stripping happens on the omp path only — no other provider emits terminal + * escapes — and has to survive streaming: a chunk boundary can fall inside an + * escape sequence, so a trailing partial sequence is held back until the next + * chunk completes it (or the turn ends and it is dropped, since a partial + * escape is not text either). + */ + +/** + * CSI (`ESC [ … final`), OSC (`ESC ] … BEL|ST`), the nF escapes that select a + * character set (`ESC ( B`), and the two-character escapes. Nothing else + * appears in omp's output, and a narrow pattern cannot eat real text by + * accident. + */ +const ANSI_PATTERN = + // eslint-disable-next-line no-control-regex + /\u001B\[[0-9;:?]*[ -/]*[@-~]|\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u001B[ -/]+[0-~]|\u001B[@-Z\\-_]/g; + +/** A tail that could still become a complete sequence once more text arrives. */ +const PARTIAL_ANSI_TAIL_PATTERN = + // eslint-disable-next-line no-control-regex + /\u001B(?:\[[0-9;:?]*[ -/]*|\][\s\S]*)?$/; + +/** Remove every terminal escape sequence from a complete string. */ +export function stripAnsi(text: string): string { + return text.includes("\u001B") ? text.replace(ANSI_PATTERN, "") : text; +} + +export interface AnsiFilter { + /** Strip escapes from a streamed chunk, holding back a partial tail. */ + readonly push: (text: string) => string; + /** Emit whatever a completed stream left buffered, minus partial escapes. */ + readonly flush: () => string; +} + +/** Streaming stripper: one per assistant stream, cheap to create. */ +export function makeAnsiFilter(): AnsiFilter { + let pending = ""; + return { + push: (text) => { + const combined = pending + text; + const partial = PARTIAL_ANSI_TAIL_PATTERN.exec(combined); + const boundary = partial === null ? combined.length : partial.index; + pending = combined.slice(boundary); + return stripAnsi(combined.slice(0, boundary)); + }, + flush: () => { + const buffered = pending; + pending = ""; + return stripAnsi(buffered.replace(PARTIAL_ANSI_TAIL_PATTERN, "")); + }, + }; +} From 1bba784643a33a815cd038f3995986a3a1c4a819 Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 16:18:31 -0300 Subject: [PATCH 15/22] feat(omp): sync session rename to the thread, survive /fresh, flag silent commands --- apps/server/scripts/acp-mock-agent.ts | 17 ++- .../Layers/ProviderRuntimeIngestion.test.ts | 21 ++++ .../Layers/ProviderRuntimeIngestion.ts | 5 +- .../src/provider/Layers/OmpAdapter.test.ts | 107 ++++++++++++++++++ apps/server/src/provider/Layers/OmpAdapter.ts | 83 +++++++++++++- .../src/provider/acp/AcpSessionRuntime.ts | 35 +++++- apps/server/src/provider/acp/OmpAcpSupport.ts | 3 + packages/contracts/src/providerRuntime.ts | 6 + 8 files changed, 267 insertions(+), 10 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 201f1ae8c6ce..5765c2ef0572 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -77,6 +77,10 @@ const emitUsageUpdate = process.env.T3_ACP_EMIT_USAGE_UPDATE === "1"; const usageUpdateSize = Number(process.env.T3_ACP_USAGE_UPDATE_SIZE ?? "1000000"); const usageUpdateUsed = Number(process.env.T3_ACP_USAGE_UPDATE_USED ?? "39451"); const emitPromptResponseUsage = process.env.T3_ACP_EMIT_PROMPT_RESPONSE_USAGE === "1"; +// omp renames its session (`/rename`, auto-titling) with session_info_update. +const sessionInfoTitle = process.env.T3_ACP_SESSION_INFO_TITLE?.trim() || undefined; +// omp's `/fresh` replaces the provider session: later updates carry a new id. +const rotateSessionIdOnPrompt = process.env.T3_ACP_ROTATE_SESSION_ID?.trim() || undefined; // omp's todo_auto_clear maps to a `plan` update with zero entries. const emitEmptyPlanAfterPlan = process.env.T3_ACP_EMIT_EMPTY_PLAN_AFTER_PLAN === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; @@ -1722,8 +1726,19 @@ const program = Effect.gen(function* () { }); } + if (sessionInfoTitle !== undefined) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "session_info_update", + title: sessionInfoTitle, + updatedAt: "2026-02-03T04:05:06.000Z", + }, + }); + } + yield* agent.client.sessionUpdate({ - sessionId: requestedSessionId, + sessionId: rotateSessionIdOnPrompt ?? requestedSessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: promptResponseText ?? "hello from mock" }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c4f57726f20f..c6c8a9da9d53 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3956,6 +3956,27 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.title).toBe("User-set title"); }); + // omp's `/rename` is the user naming the session, so it replaces a title + // this client generated rather than losing to it. + it("accepts an explicit provider rename over an existing title", async () => { + const harness = await createHarness({ threadTitle: "Generated title" }); + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-explicit"), + provider: ProviderDriverKind.make("omp"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + payload: { name: "Statusbar cache indicator", nameIsExplicit: true }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Statusbar cache indicator", + ); + expect(thread.title).toBe("Statusbar cache indicator"); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d7ae589047bf..87fa386299a8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2053,7 +2053,10 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - if (canReplaceThreadTitle(thread.title)) { + // An explicit rename in the agent (omp's `/rename`) is the session's + // own name, so it wins over a title this client guessed; a guessed + // provider name still yields to an existing title. + if (event.payload.nameIsExplicit === true || canReplaceThreadTitle(thread.title)) { yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: yield* providerCommandId(event, "thread-meta-update"), diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 0c6e06a1ce4e..9eee576a75ff 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -39,6 +39,7 @@ import { ompElicitationContentFromAnswers, ompElicitationQuestionsFromForm, parseOmpSubagentSpawns, + ompSilentCommandName, selectOmpPermissionOptionId, } from "./OmpAdapter.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; @@ -371,6 +372,23 @@ describe("selectOmpPermissionOptionId", () => { }); }); +describe("ompSilentCommandName", () => { + plainIt("names a bare command whose turn produced nothing", () => { + expect(ompSilentCommandName("/instinct-status", false)).toBe("instinct-status"); + expect(ompSilentCommandName(" /usage show ", false)).toBe("usage"); + expect(ompSilentCommandName("/agent-skills:plan", false)).toBe("agent-skills:plan"); + }); + + plainIt("stays silent when the turn answered with text", () => { + expect(ompSilentCommandName("/instinct-status", true)).toBeUndefined(); + }); + + plainIt("ignores prompts that merely start with a path", () => { + expect(ompSilentCommandName("/tmp/x is broken, fix it", false)).toBeUndefined(); + expect(ompSilentCommandName("no command here", false)).toBeUndefined(); + }); +}); + const ompAdapterTestLayer = it.layer(makeOmpAdapterTestLayer()); ompAdapterTestLayer("OmpAdapterLive", (it) => { @@ -2728,6 +2746,95 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { }), ); + // `/rename` names the session omp lists under `--resume`; the thread has to + // take that name, and take it even over a title this client generated. + it.effect("renames the thread from omp's session title", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-rename-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_SESSION_INFO_TITLE: "Statusbar cache indicator" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const metadataFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.metadata.updated", + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "/rename", attachments: [] }); + + const metadata = Array.from(yield* Fiber.join(metadataFiber))[0]; + assert.equal(metadata?.type, "thread.metadata.updated"); + if (metadata?.type === "thread.metadata.updated") { + assert.equal(metadata.payload.name, "Statusbar cache indicator"); + assert.equal(metadata.payload.nameIsExplicit, true); + } + + yield* adapter.stopSession(threadId); + }), + ); + + // `/fresh` starts a new omp provider session on the same connection: every + // later update carries the new id, and treating those as a foreign session + // left the thread silent for the rest of its life. + it.effect("keeps streaming after omp swaps its session id", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("omp-fresh-session-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_ROTATE_SESSION_ID: "mock-session-after-fresh" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + const contentFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "content.delta"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("omp"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + yield* adapter.sendTurn({ threadId, input: "/fresh", attachments: [] }); + + const delta = Array.from(yield* Fiber.join(contentFiber))[0]; + assert.equal(delta?.type, "content.delta"); + + // The cursor follows the live session, so a later resume replays the + // session omp actually kept writing to. + const sessions = yield* adapter.listSessions(); + assert.deepStrictEqual( + sessions.find((candidate) => candidate.threadId === threadId)?.resumeCursor, + { schemaVersion: 1, sessionId: "mock-session-after-fresh" }, + ); + + yield* adapter.stopSession(threadId); + }), + ); + // The migrating-terminal-user path: the session id comes from the imported // transcript on disk, and `session/load` has to reopen exactly it. it.effect("resumes an omp session named by an imported cursor", () => diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index c767eb9f1372..754db4934c77 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -176,6 +176,8 @@ interface OmpSessionContext { /** Strips omp's terminal escapes out of streamed message text. */ readonly ansiFilter: AnsiFilter; activeTurnId: TurnId | undefined; + /** Whether the active turn has streamed any assistant text yet. */ + turnProducedText: boolean; /** Context occupancy last reported by omp's `usage_update`. The * `session/prompt` response only carries per-turn tokens, so the context * meter keeps reading these until omp reports a new occupancy. */ @@ -530,6 +532,19 @@ function truncateTaskText(text: string, maxChars: number): string { return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars - 1)}…` : collapsed; } +/** + * Name the slash command whose turn produced no assistant text, or + * `undefined` when the prompt was not a bare command or text did arrive. + * Only a prompt that is *only* a command counts: a sentence that happens to + * start with a path (`/tmp/x is broken`) answers with text anyway, and a + * command with a follow-up prompt attached is an ordinary turn. + */ +export function ompSilentCommandName(prompt: string, producedText: boolean): string | undefined { + if (producedText) return undefined; + const match = /^\/([A-Za-z][\w.:-]*)(?:\s+\S+)*\s*$/.exec(prompt.trim()); + return match?.[1]; +} + function optionalTrimmedString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } @@ -1175,8 +1190,11 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv let ctx!: OmpSessionContext; // Bound after `acp.start()`; the side-channel session/update // handler below is registered before the session exists and must - // ignore notifications until the root session id is known. - let rootSessionId: string | undefined; + // ignore notifications until a session id is known. `/fresh` + // swaps omp's provider session mid-thread, so later ids join the + // set rather than replacing it: omp still answers prompts on the + // id the session was created with. + const liveSessionIds = new Set(); const resumeSessionId = parseOmpResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ @@ -1208,6 +1226,19 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + // `/fresh` swaps omp's provider session; the thread keeps running + // on the same connection, so track the new id for update routing + // and for the cursor a later resume replays. + onAgentSessionIdChanged: (sessionId) => { + liveSessionIds.add(sessionId); + const sessionCtx = sessions.get(input.threadId); + if (sessionCtx !== undefined) { + sessionCtx.session = { + ...sessionCtx.session, + resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId }, + }; + } + }, ...(mcpSession ? { mcpServers: [ @@ -1401,7 +1432,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv Effect.gen(function* () { const sessionCtx = sessions.get(input.threadId); const update = notification.update; - if (rootSessionId === undefined || notification.sessionId !== rootSessionId) { + if (!liveSessionIds.has(notification.sessionId)) { return; } // The command catalog is session state, not turn content: @@ -1461,6 +1492,25 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv }); return; } + // `/rename` (and omp's own auto-titling) renames the session + // omp shows in `--resume`. The thread title is the same name + // in this client, so it follows omp's rather than keeping a + // stale one. + if (update.sessionUpdate === "session_info_update") { + const title = typeof update.title === "string" ? update.title.trim() : undefined; + if (title === undefined || title.length === 0) { + return; + } + yield* logNative(sessionCtx.threadId, "session/update", notification); + yield* offerRuntimeEvent({ + type: "thread.metadata.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: sessionCtx.threadId, + payload: { name: title, nameIsExplicit: true }, + }); + return; + } if (update.sessionUpdate === "plan" && update.entries.length === 0) { yield* logNative(sessionCtx.threadId, "session/update", notification); yield* acp.drainEvents; @@ -1482,7 +1532,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), ), ); - rootSessionId = started.sessionId; + liveSessionIds.add(started.sessionId); const startConfiguration = yield* applyRequestedSessionConfiguration({ runtime: acp, @@ -1526,6 +1576,7 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv lastPlanFingerprint: undefined, ansiFilter: makeAnsiFilter(), activeTurnId: undefined, + turnProducedText: false, modelWarningTurnId: undefined, lastContextUsedTokens: undefined, lastContextWindowTokens: undefined, @@ -1610,6 +1661,9 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv // draws colored bars), and the escapes would render as // literal `[38;2;…m` noise. const text = ctx.ansiFilter.push(event.text); + if (event._tag === "ContentDelta") { + ctx.turnProducedText = true; + } if (text.length === 0) { return; } @@ -1693,6 +1747,9 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv // previous one. ctx.promptsInFlight += 1; ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.turnProducedText = false; + } // 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 @@ -1965,6 +2022,24 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv // superseded prompt resolving (usually cancelled) while another is // in flight or pending must leave the merged turn running. if (ctx.promptsInFlight === 1 && !ctx.stopped) { + // Some omp commands only draw into its own terminal UI + // (`/instinct-status` and friends): over ACP the turn completes + // with no text at all, which reads as "nothing happened". Name + // the command that stayed silent instead. + const silentCommand = ompSilentCommandName(input.input ?? "", ctx.turnProducedText); + if (silentCommand !== undefined) { + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + message: `Oh My Pi ran /${silentCommand} without returning any output — that command only renders in its own terminal UI.`, + detail: { command: silentCommand }, + }, + }); + } ctx.cancelledTurnIds.delete(turnId); yield* offerRuntimeEvent({ type: "turn.completed", diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..141d6fd6fa41 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -102,6 +102,15 @@ export interface AcpSessionRuntimeOptions { readonly transformSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, ) => EffectAcpSchema.SessionNotification; + /** + * Accept updates published under a session id the agent switched to on this + * same connection. omp's `/fresh` replaces the provider session and reports + * every later update under the new id; without adoption the runtime treats + * them as a foreign child session and the thread goes silent. + */ + readonly adoptAgentSessionIdChanges?: boolean; + /** Called after an adopted session id replaces the tracked one. */ + readonly onAgentSessionIdChanged?: (sessionId: string) => void; /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ readonly onStderr?: (text: string) => Effect.Effect; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; @@ -333,6 +342,10 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + // Session ids the agent switched to on this connection (omp's `/fresh`). + // Requests keep using the id the session was created with — that one is + // still routed by the agent — while updates under the new id are ours. + const adoptedSessionIdsRef = yield* Ref.make(new Set()); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -541,12 +554,26 @@ export const make = ( } // One runtime projects one root ACP session. Child-session updates need // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { + // + // An agent may replace the session behind the same connection though: + // omp's `/fresh` starts a new provider session and publishes every + // later update under the new id. Providers that opt in adopt it, or + // the connection would go silent for the rest of the thread. + if (startState._tag !== "Started") { return; } + if (notification.sessionId !== startState.result.sessionId) { + if (options.adoptAgentSessionIdChanges !== true) { + return; + } + const adopted = yield* Ref.get(adoptedSessionIdsRef); + if (!adopted.has(notification.sessionId)) { + yield* Ref.update(adoptedSessionIdsRef, (current) => + new Set(current).add(notification.sessionId), + ); + yield* Effect.sync(() => options.onAgentSessionIdChanged?.(notification.sessionId)); + } + } yield* processSessionUpdate(notification); }), ), diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index 2dce9cf53a00..fd31fbb5af77 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -101,6 +101,9 @@ export const makeOmpAcpRuntime = ( // omp/18.0.6 advertises exactly one auth method ("Use existing local // credentials"); credentials live under ~/.omp. authMethodId: "agent", + // `/fresh` starts a new provider session on the same connection and + // publishes every later update under its new id. + adoptAgentSessionIdChanges: true, // 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. diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index af1baac74f9d..9aebe529a68f 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -308,6 +308,12 @@ export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; const ThreadMetadataUpdatedPayload = Schema.Struct({ name: Schema.optional(TrimmedNonEmptyStringSchema), + /** + * The name is the agent's own session title, set by an explicit rename + * rather than guessed from the first turn, so it replaces a thread title + * this client already generated instead of yielding to it. + */ + nameIsExplicit: Schema.optional(Schema.Boolean), metadata: Schema.optional(UnknownRecordSchema), }); export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; From 067010acbcaa0092950904dc2983056e03268bac Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 16:46:26 -0300 Subject: [PATCH 16/22] docs(omp): command parity, silent commands and worktree limits --- docs/user/providers-oh-my-pi.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/user/providers-oh-my-pi.md b/docs/user/providers-oh-my-pi.md index 49b139d1e10a..eae7d87907be 100644 --- a/docs/user/providers-oh-my-pi.md +++ b/docs/user/providers-oh-my-pi.md @@ -38,6 +38,9 @@ directories omp ran in, with their conversations. | `/compact` | **Compact** button, which runs omp's compaction | | Usage limits | Quota banner from `omp usage` | | Sessions started in a terminal | Imported as threads and resumed in place | +| `/rename` | Renames the thread too — omp's session title is the thread title | +| `/fresh` | Starts omp's new provider session; the thread keeps running on it | +| `/review`, `/security`, plugins | Their pickers arrive as in-thread questions, answered in the composer | Commands that only make sense in a terminal have a T3 Code equivalent instead: the fullscreen git UI becomes the git panel and commit composer, `omp shell` and @@ -56,3 +59,11 @@ picker. - **Approval behavior is chosen when omp starts.** omp takes it from launch flags, so a thread's permission mode applies from the next turn onward rather than to work already in flight. +- **Some commands only draw in omp's terminal.** `/instinct-*` and friends + answer nothing over ACP. The turn then carries a note naming the command + that stayed silent, instead of looking like nothing happened. +- **`/wt` and `/move` move omp, not the thread.** They change the directory omp + works in; T3 Code keeps showing the project and branch the thread was opened + with. Open a thread in the worktree instead. +- **`/pin` pins in omp.** It affects omp's own resume list, not the sidebar; + pin the thread in T3 Code separately. From 297761d5460a3e77beeda44213027ca3c1837bdd Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 17:43:22 -0300 Subject: [PATCH 17/22] feat(omp): argument autocomplete from command hints, real omp logo, surface failed session load --- .../src/provider/Layers/OmpAdapter.test.ts | 6 +-- apps/server/src/provider/Layers/OmpAdapter.ts | 14 ++--- .../src/provider/acp/AcpSessionRuntime.ts | 12 +++++ apps/web/src/components/Icons.tsx | 16 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 40 +++++++++++++++ .../components/chat/ComposerCommandMenu.tsx | 8 +++ .../src/components/chat/providerIconUtils.ts | 4 +- .../chat/slashCommandArguments.test.ts | 51 +++++++++++++++++++ .../components/chat/slashCommandArguments.ts | 50 ++++++++++++++++++ .../components/onboarding/WelcomeWizard.tsx | 4 +- .../components/settings/providerDriverMeta.ts | 4 +- apps/web/src/composer-logic.test.ts | 18 +++++-- apps/web/src/composer-logic.ts | 23 ++++++++- 13 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/chat/slashCommandArguments.test.ts create mode 100644 apps/web/src/components/chat/slashCommandArguments.ts diff --git a/apps/server/src/provider/Layers/OmpAdapter.test.ts b/apps/server/src/provider/Layers/OmpAdapter.test.ts index 9eee576a75ff..08849d1c5ae5 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -2823,12 +2823,12 @@ ompAdapterTestLayer("OmpAdapterLive", (it) => { const delta = Array.from(yield* Fiber.join(contentFiber))[0]; assert.equal(delta?.type, "content.delta"); - // The cursor follows the live session, so a later resume replays the - // session omp actually kept writing to. + // The cursor stays on the id the session was created with: that is + // the one `session/load` can replay, and the swapped id is not. const sessions = yield* adapter.listSessions(); assert.deepStrictEqual( sessions.find((candidate) => candidate.threadId === threadId)?.resumeCursor, - { schemaVersion: 1, sessionId: "mock-session-after-fresh" }, + { schemaVersion: 1, sessionId: "mock-session-1" }, ); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index 754db4934c77..01da468f1f50 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -1226,18 +1226,12 @@ export function makeOmpAdapter(ompSettings: OmpSettings, options?: OmpAdapterLiv runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, - // `/fresh` swaps omp's provider session; the thread keeps running - // on the same connection, so track the new id for update routing - // and for the cursor a later resume replays. + // `/fresh` swaps omp's provider session; updates then arrive + // under the new id, so the thread tracks it for routing. The + // resume cursor keeps the id the session was created with: + // that is the one `session/load` can replay. onAgentSessionIdChanged: (sessionId) => { liveSessionIds.add(sessionId); - const sessionCtx = sessions.get(input.threadId); - if (sessionCtx !== undefined) { - sessionCtx.session = { - ...sessionCtx.session, - resumeCursor: { schemaVersion: OMP_RESUME_VERSION, sessionId }, - }; - } }, ...(mcpSession ? { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 141d6fd6fa41..641cb92616fb 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -847,6 +847,18 @@ export const make = ( cause, }), ), + // A `session/load` that dies (agent defect, malformed reply) is + // still a failed resume: as a defect it escapes the error + // channel and the caller's turn waits forever instead. + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) || Cause.hasFails(cause) + ? Effect.failCause(cause) + : new EffectAcpErrors.AcpTransportError({ + method: "session/load", + detail: `session/load failed: ${String(Cause.squash(cause))}`, + cause, + }), + ), ); return loaded; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 5b5a1ac232a2..cc9cf906c9df 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -772,6 +772,22 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); +// Official mark from https://omp.sh/favicon.svg. omp is a fork of Pi and +// carries its own logo; PiAgentIcon is upstream Pi's and is not it. +export const OmpIcon: Icon = ({ className, ...props }) => ( + +); + // Official two-color mark from https://forgejo.org/favicon.svg. export const ForgejoIcon: Icon = (props) => (
- {sources?.includes("omp") ? : null} + {sources?.includes("omp") ? : null} {threadCount} {age} diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 0818b0f86b50..c36e4aa52ea4 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -17,7 +17,7 @@ import { type Icon, OpenAI, OpenCodeIcon, - PiAgentIcon, + OmpIcon, } from "../Icons"; type ProviderSettingsSchema = { @@ -81,7 +81,7 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ { value: ProviderDriverKind.make("omp"), label: "Oh My Pi", - icon: PiAgentIcon, + icon: OmpIcon, badgeLabel: "Early Access", settingsSchema: OmpSettings, }, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3f319331bb73..49980cafc6f7 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -154,11 +154,23 @@ describe("detectComposerTrigger", () => { }); }); - it("does not keep a subcommand trigger active after /model arguments", () => { - const text = "/model spark"; + it("switches to an argument trigger once a command has a partial argument", () => { + const text = "/compact rem"; const trigger = detectComposerTrigger(text, text.length); - expect(trigger).toBeNull(); + expect(trigger).toEqual({ + kind: "slash-argument", + command: "compact", + query: "rem", + rangeStart: "/compact ".length, + rangeEnd: text.length, + }); + }); + + it("stops triggering once a second argument word is typed", () => { + const text = "/compact remote focus here"; + + expect(detectComposerTrigger(text, text.length)).toBeNull(); }); it("detects non-model slash commands while typing", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9a9354a84d8b..2dc84b7b7b3b 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -8,7 +8,12 @@ import { type ComposerPromptSegment, } from "./composer-editor-mentions"; -export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; +export type ComposerTriggerKind = + | "path" + | "pull-request" + | "slash-command" + | "slash-argument" + | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; export type ComposerSubmissionIntent = "foreground" | "background"; @@ -17,6 +22,8 @@ export interface ComposerTrigger { query: string; rangeStart: number; rangeEnd: number; + /** Command whose argument is being completed (`slash-argument` only). */ + command?: string; } export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") { @@ -222,6 +229,20 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } + // `/command `: the agent describes the argument, so the menu can + // offer its choices. Only the first argument completes — later words are + // free text (a focus instruction, a path) with nothing to enumerate. + const argumentMatch = /^\/(\S+)[ \t]+(\S*)$/.exec(linePrefix); + if (argumentMatch) { + const query = argumentMatch[2] ?? ""; + return { + kind: "slash-argument", + command: argumentMatch[1] ?? "", + query, + rangeStart: cursor - query.length, + rangeEnd: cursor, + }; + } } const tokenStart = tokenStartForCursor(text, cursor); From 14ed34f4e21946819109d2cdf17186d89b4b6d1b Mon Sep 17 00:00:00 2001 From: pedro Date: Mon, 14 Sep 2026 18:41:10 -0300 Subject: [PATCH 18/22] =?UTF-8?q?fix(omp):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20OSC=20tails,=20argument=20range,=20cancelled=20gene?= =?UTF-8?q?ration,=20cache=20freshness,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../src/provider/Drivers/OmpDriver.test.ts | 44 +++++++++++++++++++ apps/server/src/provider/Drivers/OmpDriver.ts | 18 ++++++-- .../src/provider/Drivers/OmpMaintenance.ts | 15 ++++--- apps/server/src/provider/acp/OmpAnsi.test.ts | 14 ++++++ apps/server/src/provider/acp/OmpAnsi.ts | 9 +++- .../src/textGeneration/OmpTextGeneration.ts | 14 ++++-- .../components/ComposerPromptEditor.test.ts | 4 +- apps/web/src/composer-logic.test.ts | 12 +++++ apps/web/src/composer-logic.ts | 8 +++- docs/internals/providers.md | 5 ++- docs/user/providers-oh-my-pi.md | 4 +- 12 files changed, 125 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d6decc24e653..ff6e00fc2636 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > - 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` +> - Oh My Pi (omp): install [Oh My Pi](https://github.com/can1357/oh-my-pi) and run `omp setup` > - 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/server/src/provider/Drivers/OmpDriver.test.ts b/apps/server/src/provider/Drivers/OmpDriver.test.ts index f257c1084491..aa769f587ae4 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.test.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.test.ts @@ -350,6 +350,50 @@ it.layer(testLayer)("OmpDriver", (it) => { }).pipe(Effect.scoped), ); + // A cache hit re-records the cwd so the LRU keeps it, which must not also + // restart its freshness window: a polled cwd would then never re-probe and + // a skill installed out of band would stay invisible for the session. + it.effect("re-probes after the freshness window even while the cwd is polled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-driver-stale-" }); + const probeLogPath = path.join(root, "probes.log"); + const fakePath = yield* makeFakeOmp({ + prefix: "t3-omp-driver-stale-bin-", + checkOutput: "Current version: 18.1.18\n", + probeLogPath, + }); + const instance = yield* createTestInstance("omp-catalog-stale", { + binaryPath: fakePath, + enabled: true, + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) + return yield* Effect.die("OmpDriver does not expose workspace snapshots."); + const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-stale-ws-" }); + + const realNow = Date.now; + let clockOffsetMillis = 0; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => realNow() + clockOffsetMillis); + try { + yield* snapshotForCwd(workspace); + // Polls inside the window are served from cache. + clockOffsetMillis = 20_000; + yield* snapshotForCwd(workspace); + clockOffsetMillis = 29_000; + yield* snapshotForCwd(workspace); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); + + clockOffsetMillis = 31_000; + yield* snapshotForCwd(workspace); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }).pipe(Effect.scoped), + ); + it.effect("applies a live available_commands_update without a second probe", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/OmpDriver.ts b/apps/server/src/provider/Drivers/OmpDriver.ts index b825e3e00b18..1b0bae631784 100644 --- a/apps/server/src/provider/Drivers/OmpDriver.ts +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -180,10 +180,16 @@ export const OmpDriver: ProviderDriver = { workspaceCwd: string, catalog: OmpCommandCatalog, checkedAt: string, + probedAtMillis?: number, ): void => { skillNamesByCwd.set(workspaceCwd, new Set(catalog.skills.map((skill) => skill.name))); + // The stamp is the probe's own time: re-recording a cached catalog + // keeps the cwd hot in the LRU without extending its freshness + // window, or a cwd polled faster than the window would never + // re-probe and an out-of-band skill install would stay invisible. // @effect-diagnostics-next-line globalDate:off - cache stamp shares Date.now with the freshness read below; Effect Clock is unavailable in the sync callback path. - catalogCacheByCwd.set(workspaceCwd, { catalog, cachedAt: Date.now() }); + const cachedAt = probedAtMillis ?? Date.now(); + catalogCacheByCwd.set(workspaceCwd, { catalog, cachedAt }); retainedWorkspaceSnapshots = appendOmpWorkspaceSnapshot(retainedWorkspaceSnapshots, { cwd: workspaceCwd, checkedAt, @@ -264,8 +270,14 @@ export const OmpDriver: ProviderDriver = { Effect.flatMap((machineSnapshot) => Effect.map(DateTime.now, (now) => { // Re-record so a revisited cwd moves last and an evicted one - // comes back; the timestamp slides while the cwd stays hot. - rememberCatalog(workspaceCwd, cached.catalog, DateTime.formatIso(now)); + // comes back; the probe's own timestamp carries over so the + // freshness window still expires on schedule. + rememberCatalog( + workspaceCwd, + cached.catalog, + DateTime.formatIso(now), + cached.cachedAt, + ); return { ...machineSnapshot, skills: cached.catalog.skills, diff --git a/apps/server/src/provider/Drivers/OmpMaintenance.ts b/apps/server/src/provider/Drivers/OmpMaintenance.ts index 08b7cb26eb79..f22b7b4c29ee 100644 --- a/apps/server/src/provider/Drivers/OmpMaintenance.ts +++ b/apps/server/src/provider/Drivers/OmpMaintenance.ts @@ -2,13 +2,14 @@ * OmpMaintenance — update capabilities and workspace-snapshot helpers for the * Oh My Pi (`omp`) driver. * - * omp is its own updater: it ships outside npm/homebrew, so no package - * manager owns its install. `omp update --check` prints the installed - * version (`Current version: X`) and, when behind, the version it would - * install (`New version available: Y`); `omp update` performs the install. - * The maintenance resolver therefore advertises the resolved `omp` binary - * itself as the updater and bakes the `--check` output into `latestVersion`, - * instead of guessing a registry the way npm/homebrew-backed drivers do. + * omp is its own updater: `omp update` detects how this copy was installed + * (Homebrew, mise, Bun, npm, or a direct binary) and delegates to it, so no + * single registry describes the install. `omp update --check` prints the + * installed version (`Current version: X`) and, when behind, the version it + * would install (`New version available: Y`). The maintenance resolver + * therefore advertises the resolved `omp` binary itself as the updater and + * bakes the `--check` output into `latestVersion`, instead of guessing a + * registry the way npm/homebrew-backed drivers do. * * @module provider/Drivers/OmpMaintenance */ diff --git a/apps/server/src/provider/acp/OmpAnsi.test.ts b/apps/server/src/provider/acp/OmpAnsi.test.ts index cc4ed4e44ad9..bdd40bca93d1 100644 --- a/apps/server/src/provider/acp/OmpAnsi.test.ts +++ b/apps/server/src/provider/acp/OmpAnsi.test.ts @@ -40,6 +40,20 @@ describe("makeAnsiFilter", () => { expect(filter.flush()).toBe(""); }); + it("keeps text printed after a terminated hyperlink in the same chunk", () => { + const filter = makeAnsiFilter(); + + expect(filter.push("\u001B]8;;http://x\u0007label after")).toBe("label after"); + expect(filter.flush()).toBe(""); + }); + + it("still holds an unterminated hyperlink until it closes", () => { + const filter = makeAnsiFilter(); + + expect(filter.push("start \u001B]8;;http://x")).toBe("start "); + expect(filter.push("\u0007label")).toBe("label"); + }); + it("discards a partial escape that the stream never completed", () => { const filter = makeAnsiFilter(); diff --git a/apps/server/src/provider/acp/OmpAnsi.ts b/apps/server/src/provider/acp/OmpAnsi.ts index 5543c93637c6..63ed613da3d5 100644 --- a/apps/server/src/provider/acp/OmpAnsi.ts +++ b/apps/server/src/provider/acp/OmpAnsi.ts @@ -21,10 +21,15 @@ const ANSI_PATTERN = // eslint-disable-next-line no-control-regex /\u001B\[[0-9;:?]*[ -/]*[@-~]|\u001B\][\s\S]*?(?:\u0007|\u001B\\)|\u001B[ -/]+[0-~]|\u001B[@-Z\\-_]/g; -/** A tail that could still become a complete sequence once more text arrives. */ +/** + * A tail that could still become a complete sequence once more text arrives. + * The OSC branch requires the sequence to be unterminated: a `ESC ]…BEL` + * that already closed is a complete escape, and treating it as a tail would + * withhold every character printed after it. + */ const PARTIAL_ANSI_TAIL_PATTERN = // eslint-disable-next-line no-control-regex - /\u001B(?:\[[0-9;:?]*[ -/]*|\][\s\S]*)?$/; + /\u001B(?:\[[0-9;:?]*[ -/]*|\](?:(?!\u0007|\u001B\\)[\s\S])*)?$/; /** Remove every terminal escape sequence from a complete string. */ export function stripAnsi(text: string): string { diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts index e8fcef48e701..e8608747694c 100644 --- a/apps/server/src/textGeneration/OmpTextGeneration.ts +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -133,14 +133,20 @@ export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function ), ); + // A cancelled turn is a failure even when omp already streamed + // parseable output: the text is a fragment of an answer nobody + // finished, not a result. + if (promptResult.stopReason === "cancelled") { + return yield* new TextGenerationError({ + operation, + detail: "Oh My Pi ACP request was cancelled.", + }); + } 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.", + detail: "Oh My Pi returned empty output.", }); } diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 9f97b93b057a..50ea3b8d00b2 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -873,9 +873,7 @@ describe("isOpenableSkillPath", () => { }); it("refuses internal URLs and blank paths", () => { - expect( - isOpenableSkillPath("C:\Storage\AI\Agents\.omp\curated-skills\matt\matt-tdd\SKILL.md"), - ).toBe(false); + expect(isOpenableSkillPath("skill://tdd/SKILL.md")).toBe(false); expect(isOpenableSkillPath("https://example.com/SKILL.md")).toBe(false); expect(isOpenableSkillPath(" ")).toBe(false); }); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 49980cafc6f7..e15840670dca 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -167,6 +167,18 @@ describe("detectComposerTrigger", () => { }); }); + it("covers the whole argument token when the caret sits inside it", () => { + const text = "/compact remx"; + + expect(detectComposerTrigger(text, "/compact rem".length)).toEqual({ + kind: "slash-argument", + command: "compact", + query: "rem", + rangeStart: "/compact ".length, + rangeEnd: text.length, + }); + }); + it("stops triggering once a second argument word is typed", () => { const text = "/compact remote focus here"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2dc84b7b7b3b..24f3a98c492a 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -235,12 +235,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const argumentMatch = /^\/(\S+)[ \t]+(\S*)$/.exec(linePrefix); if (argumentMatch) { const query = argumentMatch[2] ?? ""; + // The caret may sit inside the argument (`/compact rem|x`). Selecting a + // choice replaces the whole token, not just the part before the caret, + // or the leftover would trail the inserted value. + const lineEnd = text.indexOf("\n", cursor); + const restOfLine = text.slice(cursor, lineEnd === -1 ? text.length : lineEnd); + const tokenRest = /^\S*/.exec(restOfLine)?.[0] ?? ""; return { kind: "slash-argument", command: argumentMatch[1] ?? "", query, rangeStart: cursor - query.length, - rangeEnd: cursor, + rangeEnd: cursor + tokenRest.length, }; } } diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 71157823cd6f..579194e5a86f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -137,8 +137,9 @@ Token and cost reporting arrive as an unstable ACP extension: omp sends `usage_u totals on the prompt response. `usedTokens` tracks context occupancy, so per-turn totals must not overwrite it or the meter shrinks every turn. -omp ships outside npm and Homebrew and updates itself: `omp update --check` reports the current -and latest version and `omp update` installs, which is the only advisory source T3 trusts for it. +omp updates itself through whichever installer it detects (Homebrew, mise, Bun, npm, or a direct +binary), so no registry describes it: `omp update --check` reports the current and latest version +and `omp update` installs, which is the only advisory source T3 trusts for it. Subscription limits come from `omp usage --json`, which enumerates authenticated accounts per provider and doubles as the auth probe — a failed probe must stay `unknown` rather than report `unauthenticated`. diff --git a/docs/user/providers-oh-my-pi.md b/docs/user/providers-oh-my-pi.md index eae7d87907be..e65685cd07c6 100644 --- a/docs/user/providers-oh-my-pi.md +++ b/docs/user/providers-oh-my-pi.md @@ -15,7 +15,9 @@ the accounts omp is authenticated with. If `omp` is not on the server's `PATH`, set **Binary path** on the card. When omp is behind its latest release, the card offers **Update now**, which runs -omp's own updater (`omp update`). No other installer is involved. +`omp update`. omp installs through whichever route it finds your copy came from +— Homebrew, mise, Bun, npm, or its own binary — so the update matches how you +installed it. **Setup** (the first-run wizard) lists Oh My Pi next to Claude Code and Codex, with the same inline terminal: **Install** pre-types omp's own installer, and From 959429b717844eba32d881401e46d5e9e26bff92 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:50:32 -0400 Subject: [PATCH 19/22] fix(omp): satisfy current-main lint in the OMP integration `vp lint` on the integrated files surfaced one error and dead imports carried from the PR: - AgentSessionScanner.test.ts read `NodeOS.platform()`, which current main's `t3code/no-global-process-runtime` rule rejects. Use `HostProcessPlatform.defaultValue()`, the pattern main's own tests use for module-scope platform checks. - Drop unused `Dialog*`/`PiAgentIcon` imports in AddProviderInstanceDialog after the PR removed the Pi Agent coming-soon option, plus unused `EffectAcpSchema` and `acpPermissionOutcome` imports in OMP files. No behavior change. --- apps/server/src/project/AgentSessionScanner.test.ts | 4 ++-- apps/server/src/provider/Layers/OmpAdapter.ts | 2 +- apps/server/src/provider/acp/OmpAcpSupport.ts | 1 - .../components/settings/AddProviderInstanceDialog.tsx | 11 ++--------- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index b209a3486549..5eedf6b9fe81 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -9,7 +9,7 @@ import { type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -209,7 +209,7 @@ const ompProviderInstances = ( * uses backslashes, which is what a migrating terminal user actually has. */ const ompSessionCwd = (cwd: string) => - NodeOS.platform() === "win32" ? cwd.replaceAll("/", "\\") : cwd; + HostProcessPlatform.defaultValue() === "win32" ? cwd.replaceAll("/", "\\") : cwd; const ompSessionLine = (input: { readonly cwd: string; readonly sessionId: string }) => `${encodeTranscriptRecord({ diff --git a/apps/server/src/provider/Layers/OmpAdapter.ts b/apps/server/src/provider/Layers/OmpAdapter.ts index 01da468f1f50..1dc45cba8fce 100644 --- a/apps/server/src/provider/Layers/OmpAdapter.ts +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -55,7 +55,7 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index fd31fbb5af77..c779a37f05f9 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -9,7 +9,6 @@ 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, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index f6cb57ecc3c0..af8b6fba4fb0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,15 +14,8 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook 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, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { ACPRegistryIcon, Gemini, GithubCopilotIcon, type Icon } from "../Icons"; +import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; From 2d713044d609d82b8eda3802659ce2da5c09605e Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:52:55 -0400 Subject: [PATCH 20/22] fix(omp): drop no-op effect-diagnostics directives for current typecheck Current `@effect/tsgo` reports `@effect-diagnostics-next-line preferSchemaOverJson:off` as TS377000 ("directive has no effect") on 11 OMP lines. effect-tsgo promotes those warnings to a non-zero `tsc --noEmit` exit, so the server typecheck failed on warnings alone. The directives suppress nothing on this toolchain; remove exactly the 11 flagged lines and let fmt reflow one JSON.stringify block. Server typecheck now has 0 errors and 0 warnings (suggestions only). No behavior change. --- .../src/provider/Drivers/OmpCommands.test.ts | 17 ++++++----------- apps/server/src/provider/Drivers/OmpCommands.ts | 1 - .../provider/Drivers/OmpModelCatalog.test.ts | 4 ---- .../src/provider/Drivers/OmpModelCatalog.ts | 1 - .../src/provider/Layers/OmpProvider.test.ts | 1 - 5 files changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts index 624bf801077c..eb24ce298c0b 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.test.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -15,13 +15,11 @@ import { import { writeFakeCli } from "../../testUtils/fakeCli.ts"; const frame = (commands: ReadonlyArray) => - // @effect-diagnostics-next-line preferSchemaOverJson:off - building a raw RPC frame. JSON.stringify({ type: "available_commands_update", commands }); describe("decodeOmpCommandCatalog", () => { it("splits skills from slash commands, each sorted by name", () => { const stdout = [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. JSON.stringify({ type: "ready", protocolVersion: 1 }), frame([ { name: "skill:tdd", description: "Test-driven development." }, @@ -79,14 +77,12 @@ describe("decodeOmpCommandCatalog", () => { }); it("reads the response payload of an explicit command request", () => { - const stdout = - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. - JSON.stringify({ - type: "response", - command: "get_available_commands", - success: true, - data: { commands: [{ name: "skill:deploy" }, { name: "share" }] }, - }); + const stdout = JSON.stringify({ + type: "response", + command: "get_available_commands", + success: true, + data: { commands: [{ name: "skill:deploy" }, { name: "share" }] }, + }); const catalog = decodeOmpCommandCatalog(stdout); expect(catalog.skills).toEqual([ @@ -134,7 +130,6 @@ describe("decodeOmpCommandCatalog", () => { }); it("returns empty catalogs when the output carries no command frame", () => { - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. expect(decodeOmpCommandCatalog(JSON.stringify({ type: "ready" }))).toEqual({ skills: [], slashCommands: [], diff --git a/apps/server/src/provider/Drivers/OmpCommands.ts b/apps/server/src/provider/Drivers/OmpCommands.ts index b8f31dae4589..2e99d6d8ac66 100644 --- a/apps/server/src/provider/Drivers/OmpCommands.ts +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -157,7 +157,6 @@ export function decodeOmpCommandCatalog(stdout: string): OmpCommandCatalog { if (trimmedLine.length === 0) continue; let frame: unknown; try { - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. frame = JSON.parse(trimmedLine); } catch { continue; diff --git a/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts index 8c35cb3695ae..f8877397bb8d 100644 --- a/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts @@ -49,15 +49,12 @@ const ompModelEntries = [ ] as const; const rpcTranscript = [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. JSON.stringify({ type: "ready" }), - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. JSON.stringify({ type: "available_commands_update", commands: [{ name: "compact", description: "Compact the context" }], }), "not json", - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frames. JSON.stringify({ type: "response", command: "get_available_models", @@ -194,7 +191,6 @@ describe("decodeOmpModelCatalog", () => { }); it("returns an empty catalog when omp answered no model response", () => { - // @effect-diagnostics-next-line preferSchemaOverJson:off - raw RPC frame. const catalog = decodeOmpModelCatalog(JSON.stringify({ type: "ready" })); expect(catalog.models).toEqual([]); diff --git a/apps/server/src/provider/Drivers/OmpModelCatalog.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.ts index 39c3e4051f96..d81047617509 100644 --- a/apps/server/src/provider/Drivers/OmpModelCatalog.ts +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.ts @@ -298,7 +298,6 @@ export function decodeOmpModelCatalog(stdout: string): OmpModelCatalog { if (trimmedLine.length === 0) continue; let frame: unknown; try { - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSONL transport frame. frame = JSON.parse(trimmedLine); } catch { continue; diff --git a/apps/server/src/provider/Layers/OmpProvider.test.ts b/apps/server/src/provider/Layers/OmpProvider.test.ts index a282d23ae329..792a172a47be 100644 --- a/apps/server/src/provider/Layers/OmpProvider.test.ts +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -291,7 +291,6 @@ const makeRpcCatalogOmpWrapper = Effect.fn("makeRpcCatalogOmpWrapper")(function* ` process.stdout.write(${JSON.stringify(`${commandFrame}\n`)});`, " const chunks = [];", " for await (const chunk of process.stdin) chunks.push(chunk);", - // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. ` if (Buffer.concat(chunks).toString("utf8").includes("get_available_models")) {`, // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. ` process.stdout.write(${JSON.stringify(`${modelsResponse}\n`)});`, From 0a3e6c72eacb497a015c2d21328987b537120877 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:14:11 -0400 Subject: [PATCH 21/22] fix(omp): deny tools during unattended text generation Unattended commit/PR/branch/title generation spawned omp with --auto-approve and registered no permission handlers, so repository-derived prompt text could steer the model into write or destructive tool work that ran without any user in the loop. Spawn those sessions with --no-tools and always-ask instead, and answer any remaining permission request with a refusal and any elicitation with a decline, so nothing can pause on UI that does not exist. The interactive provider is unchanged. --- .../src/provider/acp/OmpAcpSupport.test.ts | 29 +++++++ apps/server/src/provider/acp/OmpAcpSupport.ts | 40 ++++++---- .../textGeneration/OmpTextGeneration.test.ts | 76 +++++++++++++++++-- .../src/textGeneration/OmpTextGeneration.ts | 30 ++++++-- 4 files changed, 152 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts index 86ee94ab78e9..3f71f1f6708a 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.test.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -58,6 +58,23 @@ describe("ompAcpSpawnArgs", () => { expect(ompAcpSpawnArgs("auto")).toEqual(["acp", "--auto-approve"]); expect(ompAcpSpawnArgs("full-access")).toEqual(["acp", "--approval-mode=yolo"]); }); + + it("appends --no-tools only when tools are explicitly disabled", () => { + expect(ompAcpSpawnArgs("approval-required", { disableTools: true })).toEqual([ + "acp", + "--approval-mode=always-ask", + "--no-tools", + ]); + expect(ompAcpSpawnArgs("approval-required", { disableTools: false })).toEqual([ + "acp", + "--approval-mode=always-ask", + ]); + expect(ompAcpSpawnArgs("full-access", { disableTools: true })).toEqual([ + "acp", + "--approval-mode=yolo", + "--no-tools", + ]); + }); }); describe("buildOmpAcpSpawnInput", () => { @@ -93,6 +110,18 @@ describe("buildOmpAcpSpawnInput", () => { env: environment, }); }); + + it("forwards the disableTools option into the spawn args", () => { + expect( + buildOmpAcpSpawnInput(undefined, "/tmp/project", undefined, "approval-required", { + disableTools: true, + }), + ).toEqual({ + command: "omp", + args: ["acp", "--approval-mode=always-ask", "--no-tools"], + cwd: "/tmp/project", + }); + }); }); describe("resolveOmpAcpBaseModelId", () => { diff --git a/apps/server/src/provider/acp/OmpAcpSupport.ts b/apps/server/src/provider/acp/OmpAcpSupport.ts index c779a37f05f9..1567e4c75ded 100644 --- a/apps/server/src/provider/acp/OmpAcpSupport.ts +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -36,6 +36,12 @@ export interface OmpAcpRuntimeInput extends Omit< * answers. */ readonly enableElicitation?: boolean; + /** + * Whether to spawn omp with `--no-tools` (default false), removing every + * built-in tool from the session. Unattended text generation passes true: + * the model gets no tool surface to attempt, on top of the approval mode. + */ + readonly disableTools?: boolean; } export interface OmpAcpModelSelectionErrorContext { @@ -52,18 +58,24 @@ export interface OmpAcpModelSelectionErrorContext { * `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 ompAcpSpawnArgs( + runtimeMode?: RuntimeMode, + options?: { readonly disableTools?: boolean }, +): ReadonlyArray { + const args = (() => { + 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"]; + } + })(); + return options?.disableTools === true ? [...args, "--no-tools"] : args; } export function buildOmpAcpSpawnInput( @@ -71,10 +83,11 @@ export function buildOmpAcpSpawnInput( cwd: string, environment?: NodeJS.ProcessEnv, runtimeMode?: RuntimeMode, + options?: { readonly disableTools?: boolean }, ): AcpSessionRuntime.AcpSpawnInput { return { command: ompSettings?.binaryPath || "omp", - args: [...ompAcpSpawnArgs(runtimeMode)], + args: [...ompAcpSpawnArgs(runtimeMode, options)], cwd, ...(environment ? { env: environment } : {}), }; @@ -96,6 +109,7 @@ export const makeOmpAcpRuntime = ( input.cwd, input.environment, input.runtimeMode, + input.disableTools === true ? { disableTools: true } : undefined, ), // omp/18.0.6 advertises exactly one auth method ("Use existing local // credentials"); credentials live under ~/.omp. diff --git a/apps/server/src/textGeneration/OmpTextGeneration.test.ts b/apps/server/src/textGeneration/OmpTextGeneration.test.ts index e6983ff94f46..7b96a2051f42 100644 --- a/apps/server/src/textGeneration/OmpTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -11,11 +11,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; 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 { OmpSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; @@ -83,7 +84,7 @@ async function waitForFileContent(filePath: string, attempts = 400): Promise { - it.effect("spawns omp acp with --auto-approve for unattended background generation", () => { + it.effect("spawns omp acp with tools and auto-approval disabled", () => { const requestLogDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-omp-text-log-"), ); @@ -116,13 +117,14 @@ it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { 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. + // Unattended generation must run without a tool surface and without + // auto-approval: repository-derived text can steer the model, so + // `--no-tools` plus always-ask keeps it from mutating anything. const argvLog = NodeFS.readFileSync(argvLogPath, "utf8") .trim() .split("\n") .map((line) => line.split("\t")); - expect(argvLog).toEqual([["acp", "--auto-approve"]]); + expect(argvLog).toEqual([["acp", "--approval-mode=always-ask", "--no-tools"]]); const requests = NodeFS.readFileSync(requestLogPath, "utf8") .trim() @@ -198,6 +200,70 @@ it.layer(OmpTextGenerationTestLayer)("OmpTextGeneration", (it) => { ), ); + it.effect("denies tool permission requests instead of approving them", () => + withFakeAcpAgent( + { + // The mock asks to run a terminal command before answering. With a + // permission handler that denies, the turn is cancelled instead of + // executing anything; without one this path must still fail fast. + T3_ACP_EMIT_TOOL_CALLS: "1", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/omp-permission-deny", + 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", + }, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.detail).toContain("cancelled"); + expect(result.failure.detail).not.toContain("timed out"); + } + }), + ), + ); + + it.effect("declines elicitation so it cannot block unattended generation", () => + withFakeAcpAgent( + { + // The mock asks for user input before answering. The decline must + // come back immediately; generation can then fail on the mock's + // non-JSON reply, but it must not wait on a user who is not there. + T3_ACP_EMIT_ELICITATION: "1", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Write a title without asking for input.", + modelSelection: { + instanceId: ProviderInstanceId.make("omp"), + model: "zhipu-coding-plan/glm-5.3", + }, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.detail).not.toContain("timed out"); + } + }), + ), + ); + // Windows terminates the child instead of signalling it, so the mock // agent never reaches its exit handler and writes no exit log. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( diff --git a/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts index e8608747694c..10848dacdd09 100644 --- a/apps/server/src/textGeneration/OmpTextGeneration.ts +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type OmpSettings, type ModelSelection } from "@t3tools/contracts"; @@ -64,11 +65,15 @@ export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function 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", + // Unattended generation must not be able to mutate anything. The + // prompt is self-contained (no tool work is needed), so the session + // spawns with every built-in tool disabled, and anything that still + // asks for approval is answered with a refusal instead of pausing on + // UI that does not exist. Repository-derived text can steer the + // model, and that must not turn into a write merely because metadata + // generation is running. + runtimeMode: "approval-required", + disableTools: true, // 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. @@ -76,6 +81,21 @@ export const makeOmpTextGeneration = Effect.fn("makeOmpTextGeneration")(function clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + // Defense in depth for approval layers that do not consult the spawn + // flags: a tool permission request is denied and an elicitation is + // declined, so neither can wait for a user who is not there. + yield* runtime.handleRequestPermission(() => + Effect.succeed({ outcome: { outcome: "cancelled" as const } }), + ); + yield* runtime.handleElicitation(() => + Effect.succeed({ action: { action: "decline" as const } }), + ); + yield* runtime.handleUnknownExtRequest((method) => + method === "elicitation/create" + ? Effect.succeed({ action: "decline" as const }) + : Effect.fail(EffectAcpErrors.AcpRequestError.methodNotFound(method)), + ); + yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; if (update.sessionUpdate !== "agent_message_chunk") { From 8f021c5f389eec3a9a7d7f9266c623686792a2b5 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:14:18 -0400 Subject: [PATCH 22/22] fix(omp): bound the usage probe for cold quota refreshes The omp usage --json probe refreshes provider quota over the network. Warm runs measure 0.5-1.3s but the first refresh after an omp update measured 4.8s, so the 3s bound dropped a healthy account's limits and could transiently degrade the snapshot. 10s matches omp's own update --check bound and still fails rather than hanging a refresh. --- apps/server/src/provider/Drivers/OmpUsage.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/OmpUsage.ts b/apps/server/src/provider/Drivers/OmpUsage.ts index 224bc87704bd..c09550ccbf71 100644 --- a/apps/server/src/provider/Drivers/OmpUsage.ts +++ b/apps/server/src/provider/Drivers/OmpUsage.ts @@ -40,8 +40,15 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { spawnAndCollect } from "../providerSnapshot.ts"; import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; -/** Bound for the read-only `omp usage --json` probe (mirrors Codex's rate-limits probe). */ -export const OMP_USAGE_PROBE_TIMEOUT_MS = 3_000; +/** + * Bound for the read-only `omp usage --json` probe (mirrors Codex's + * rate-limits probe). This probe refreshes provider quota over the network: + * warm runs measure 0.5-1.3s, and the first refresh after an omp update + * measured 4.8s, so a 3s bound degraded a healthy account's limits under an + * ordinary cold refresh. 10s matches omp's own `update --check` bound and + * still fails rather than hanging a snapshot refresh. + */ +export const OMP_USAGE_PROBE_TIMEOUT_MS = 10_000; // Schema Structs ignore unknown keys by default, so forward-compatible CLI // additions decode fine; every field below stays optional so one missing key