diff --git a/README.md b/README.md index 27b5dc491693..ff6e00fc2636 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,12 +13,13 @@ 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` +> - 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` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. 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", 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..5765c2ef0572 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -51,7 +51,40 @@ 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"; +// 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. +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"; +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 +102,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 +154,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 +381,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 { @@ -381,6 +475,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) { @@ -408,7 +544,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() }, @@ -551,6 +692,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 +724,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(), }; @@ -656,6 +803,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"); } @@ -989,6 +1155,224 @@ 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, + }, + }); + + // 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: { + 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"; @@ -1323,15 +1707,58 @@ 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" }, + }, + }); + } + + 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" }, }, }); - 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/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 062a91f8cb53..d582ca4f657a 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 ee4d08c36d5a..97a5dd9fa16e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2065,7 +2065,14 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - if (thread.titleState?.source !== "manual" && 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, and a manual + // rename in T3 wins over both. + if ( + thread.titleState?.source !== "manual" && + (event.payload.nameIsExplicit === true || canReplaceThreadTitle(thread.title)) + ) { yield* orchestrationEngine.dispatch({ type: "thread.title.generate.complete", commandId: yield* providerCommandId(event, "thread-meta-update"), 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 b1cba460f959..5eedf6b9fe81 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, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -77,6 +78,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) => @@ -97,6 +104,9 @@ const makeScannerTestLayer = (input: ScannerTestInput) => input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ...(input.hostEnvironment === undefined + ? [] + : [Layer.succeed(HostProcessEnvironment, input.hostEnvironment)]), ), ), ); @@ -178,6 +188,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) => + HostProcessPlatform.defaultValue() === "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", () => @@ -401,68 +443,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", () => @@ -999,31 +1045,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", () => @@ -1370,6 +1418,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", () => { @@ -2606,6 +2774,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", + }, + ], + }, + ]); + }), + ); }); }); @@ -3215,4 +3499,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/server/src/provider/Drivers/OmpCommands.test.ts b/apps/server/src/provider/Drivers/OmpCommands.test.ts new file mode 100644 index 000000000000..eb24ce298c0b --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpCommands.test.ts @@ -0,0 +1,256 @@ +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, + discoverOmpCommandCatalog, +} from "./OmpCommands.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const frame = (commands: ReadonlyArray) => + JSON.stringify({ type: "available_commands_update", commands }); + +describe("decodeOmpCommandCatalog", () => { + it("splits skills from slash commands, each sorted by name", () => { + const stdout = [ + 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 = 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", () => { + expect(decodeOmpCommandCatalog(JSON.stringify({ type: "ready" }))).toEqual({ + skills: [], + slashCommands: [], + }); + }); +}); + +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))); + }); +}); + +/** + * 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 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);", + "", + ].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, + ); + 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 new file mode 100644 index 000000000000..2e99d6d8ac66 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpCommands.ts @@ -0,0 +1,267 @@ +/** + * 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 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 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 + */ +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"; +import { + decodeOmpModelCatalog, + OMP_AVAILABLE_MODELS_REQUEST_ID, + OMP_STATE_REQUEST_ID, + type OmpModelCatalog, +} from "./OmpModelCatalog.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; +} + +/** + * 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", + { + 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 : []; +} + +/** + * 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 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; + let frame: unknown; + try { + frame = JSON.parse(trimmedLine); + } catch { + continue; + } + if (typeof frame !== "object" || frame === null) continue; + for (const entry of commandEntriesFromFrame(frame as Record)) { + entries.push(entry); + } + } + return catalogFromCommandEntries(entries); +} + +/** + * 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 two + // catalogs, 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, + }), + ); + // 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), + // 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`, + // 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, + ).pipe(Effect.ignore), + 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); + 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({ + stage: "exit", + ...(cwd ? { cwd } : {}), + exitCode: probe.value.exitCode, + }); + } + return { ...catalog, models }; +}); 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..aa769f587ae4 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.test.ts @@ -0,0 +1,463 @@ +// @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 { 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( + 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; + readonly probeLogPath?: string; +}): string { + return [ + 'import { appendFileSync, 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") {', + ...(input.probeLogPath + ? [ + // 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);", + "}", + '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; + readonly probeLogPath?: 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";', + ...(options.probeLogPath ? { probeLogPath: options.probeLogPath } : {}), + }), + }); +}); + +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 }, + }); + +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, cwd?: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fileSystem + .readFileString(probeLogPath) + .pipe(Effect.orElseSucceed(() => "")); + 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) => { + 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), + ); + + 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, workspace)).toBe(1); + + const other = yield* fs.makeTempDirectoryScoped({ prefix: "t3-omp-cache-other-" }); + yield* snapshotForCwd(other); + expect(yield* readProbeCount(probeLogPath, other)).toBe(1); + expect(yield* readProbeCount(probeLogPath, workspace)).toBe(1); + }).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; + 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, workspace)).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, workspace)).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 new file mode 100644 index 000000000000..1b0bae631784 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpDriver.ts @@ -0,0 +1,341 @@ +/** + * 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, + 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"; +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 { + catalogFromCommandEntries, + discoverOmpCommandCatalog, + type OmpCommandCatalog, +} from "./OmpCommands.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 { + 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"); + +/** + * 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 + | 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 fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OmpSettings; + + // 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>(); + // 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, + 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. + const cachedAt = probedAtMillis ?? Date.now(); + catalogCacheByCwd.set(workspaceCwd, { catalog, cachedAt }); + 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); + + 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, + 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 }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichOmpSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + 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, + }), + ), + ); + 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 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, + 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 + // probe and publishes when the catalog moved. + const refreshModels: NonNullable = Effect.fn( + "OmpDriver.refreshModels", + )(() => snapshot.refresh.pipe(Effect.asVoid)); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + 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..f22b7b4c29ee --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpMaintenance.ts @@ -0,0 +1,160 @@ +/** + * OmpMaintenance — update capabilities and workspace-snapshot helpers for the + * Oh My Pi (`omp`) driver. + * + * 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 + */ +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/OmpModelCatalog.test.ts b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts new file mode 100644 index 000000000000..f8877397bb8d --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.test.ts @@ -0,0 +1,199 @@ +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 = [ + JSON.stringify({ type: "ready" }), + JSON.stringify({ + type: "available_commands_update", + commands: [{ name: "compact", description: "Compact the context" }], + }), + "not json", + 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", () => { + 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..d81047617509 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpModelCatalog.ts @@ -0,0 +1,313 @@ +/** + * 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"; + +/** 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; + * 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, + activeSlug?: string | undefined, +): 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, + // 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, + }), + }); + } + 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 : []; +} + +/** + * The slug omp has selected, read from a `get_state` response. omp reports + * the model as `{ provider, id }`, which is the same pair the ACP `model` + * select advertises as `/`. + */ +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`, + * `extension_ui_request`) are skipped, so the same transcript can also feed + * the command catalog. + */ +export function decodeOmpModelCatalog(stdout: string): OmpModelCatalog { + const entries: Array = []; + let activeSlug: string | undefined; + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) continue; + let frame: unknown; + try { + frame = JSON.parse(trimmedLine); + } catch { + continue; + } + if (typeof frame !== "object" || frame === null) continue; + const record = frame as Record; + activeSlug = activeSlugFromFrame(record) ?? activeSlug; + for (const entry of modelEntriesFromFrame(record)) { + entries.push(entry); + } + } + return catalogFromOmpModelEntries(entries, activeSlug); +} 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/OmpUsage.test.ts b/apps/server/src/provider/Drivers/OmpUsage.test.ts new file mode 100644 index 000000000000..b28f9c0774eb --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpUsage.test.ts @@ -0,0 +1,391 @@ +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", + metadata: { accountId: "acct-1", email: "dev@example.com" }, + 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("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({ + 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" }); + 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", + email: "dev@example.com", + 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..c09550ccbf71 --- /dev/null +++ b/apps/server/src/provider/Drivers/OmpUsage.ts @@ -0,0 +1,448 @@ +/** + * 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). 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 +// 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), +}); + +// 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(OmpUsageTimestampSchema), +}); + +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 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(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), + // 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(OmpUsageTimestampSchema), + // 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 reports = payload ? decodeReports(payload) : []; + const providers = payload ? ompUsageProviders(payload) : []; + if (providers.length === 0) { + return { status: "unknown" }; + } + // The card only names an account when it has an address; without one an + // authenticated provider reads as a bare status line. + const email = reports + .map((report) => 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]! + : `${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; +} + +/** 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 { + 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 = ompTimestampToMillis(limit.window?.resetsAt); + 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 new file mode 100644 index 000000000000..08849d1c5ae5 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.test.ts @@ -0,0 +1,3118 @@ +// 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, + ompSilentCommandName, + 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`. +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"); +// 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-")); + 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( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "omp-acp-probe-")); + 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) { + 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, + ), + ); +}); + +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, + Effect.gen(function* () { + const ompConfig = decodeOmpSettings({}); + const resolveSettings = yield* makeResolveOmpSettings; + return yield* makeOmpAdapter(ompConfig, { + ...(instanceId ? { instanceId } : {}), + resolveSettings, + resolveSkillNames: () => new Set(["tdd"]), + onSessionCommands: (cwd, commands) => { + sessionCommandCalls.push({ cwd, commands }); + }, + }); + }), + ).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(); + }); +}); + +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) => { + 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.skipIf(windowsHost)("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.skipIf(windowsHost)( + "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("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", + () => + 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), + ); + + 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); + }), + ); + + // `/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 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-1" }, + ); + + 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", () => + Effect.gen(function* () { + const adapter = yield* OmpAdapter; + const settings = yield* ServerSettingsService; + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_OMP_SESSION_STORE: "1" }), + ); + yield* settings.updateSettings({ providers: { omp: { binaryPath: wrapperPath } } }); + + 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: { schemaVersion: 1, sessionId: "omp-session-terminal-1" }, + }); + 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 new file mode 100644 index 000000000000..1dc45cba8fce --- /dev/null +++ b/apps/server/src/provider/Layers/OmpAdapter.ts @@ -0,0 +1,2255 @@ +/** + * 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 RuntimeTaskStatus, + type RuntimeTaskUsage, + type ThreadId, + type ThreadTokenUsageSnapshot, + 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 { 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, + sessionUpdateIsReplay, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyOmpAcpModelSelection, + makeOmpAcpRuntime, + 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"; +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; + /** + * 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; + /** + * 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 { + 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>; + /** 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; + /** 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. */ + 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. */ + 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; +} + +/** + * 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; +} + +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] : []; +} + +/** + * 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 { + 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 { + 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"); + 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, + }), + ); + }); + + /** + * 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 + * 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* () { + 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, + }, + }); + } + } + + 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); + 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()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(spawn.taskId), + 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, + }, + }); + } + }); + + 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; + // Bound after `acp.start()`; the side-channel session/update + // handler below is registered before the session exists and must + // 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({ + 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" }, + // `/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); + }, + ...(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); + }); + // 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 (!liveSessionIds.has(notification.sessionId)) { + 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 || + sessionUpdateIsReplay(notification) + ) { + return; + } + 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; + } + // `/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; + 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), + ), + ); + liveSessionIds.add(started.sessionId); + + const startConfiguration = 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: startConfiguration.model ?? resolveOmpAcpBaseModelId(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(), + ompSubagentActivity: new Map(), + turns: [], + lastPlanFingerprint: undefined, + ansiFilter: makeAnsiFilter(), + activeTurnId: undefined, + turnProducedText: false, + modelWarningTurnId: undefined, + lastContextUsedTokens: undefined, + lastContextWindowTokens: 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": { + // 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(), + 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); + // 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 (event._tag === "ContentDelta") { + ctx.turnProducedText = true; + } + if (text.length === 0) { + return; + } + 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, + 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; + 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 + // 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()) { + 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) { + // 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; + + // 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, + 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 } : {}), + }; + + // 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. + 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", + ...(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 }, + // 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, + 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..792a172a47be --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.test.ts @@ -0,0 +1,1042 @@ +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"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +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 } + : {}), + }; +} + +/** + * 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, +) { + const fileSystem = yield* FileSystem.FileSystem; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-mock-", + }); + 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 mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "omp-provider-version-mock-", + }); + return writeFakeCli({ + directory: dir, + 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);", + "}", + execScriptSource({ scriptPath: mockAgentPath }), + ].join("\n"), + }); +}); + +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: [ + RPC_UNSUPPORTED_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"), + }); +}); + +/** + * 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);", + ` 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, +) { + 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"]); + }); + + 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", () => { + 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, + }, + ], + }); + }); + + 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(); + }); + + 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", () => { + 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: [] }), + ); + }); + + 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("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([ + { + type: "select", + currentValue: "1m", + options: [ + { name: "272K", value: "272k" }, + { name: "1M", value: "1m" }, + ], + category: "model_config", + id: "context_size", + name: "Context", + }, + { + type: "boolean", + currentValue: true, + category: "model_config", + id: "fast", + name: "Fast", + }, + ]), + ).toEqual(createModelCapabilities({ optionDescriptors: [] })); + expect( + 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([]); + }); +}); + +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("falls back to the ACP catalog when the RPC probe answers no models", () => + 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"); + }), + ); + 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, + ]); + }), + ); + + 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", () => { + 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("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")( + "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("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" }]), + ).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..bf0a8c1b9fd8 --- /dev/null +++ b/apps/server/src/provider/Layers/OmpProvider.ts @@ -0,0 +1,748 @@ +import type { + OmpSettings, + ModelCapabilities, + ProviderOptionSelection, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderSkill, + ServerProviderSlashCommand, + ServerProviderState, + ServerProviderUsageLimits, +} 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, + getProviderOptionStringSelectionValue, + readCustomModelEntries, +} from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + 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"; +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", + 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: [], +}); + +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.", + "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 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] + ); +} + +export function buildOmpCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + 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 || seenReasoningValues.has(normalizedValue)) { + return []; + } + seenReasoningValues.add(normalizedValue); + return [ + { + value: normalizedValue, + label: entry.name, + ...(normalizeOmpReasoningValue(reasoningConfig.currentValue) === normalizedValue + ? { isDefault: true } + : {}), + }, + ]; + }) + : []; + + // 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", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []; + + 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", + ) + ); +} + +/** + * 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 []; + } + // 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); + 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, + ); + +/** + * 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 withUnknownBareCustomCapabilities( + providerModelsFromSettings([], ompSettings.customModels, EMPTY_CAPABILITIES), + ompSettings.customModels, + ); +} + +function findOmpSelectOptionValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + matcher: (option: OmpSessionSelectOption) => boolean, +): string | undefined { + return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; +} + +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 }); + } + } + + 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; + /** + * 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 { + const status = input.status ?? "ready"; + 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: withUnknownBareCustomCapabilities( + providerModelsFromSettings( + input.discoveredModels ?? [], + input.ompSettings.customModels, + EMPTY_CAPABILITIES, + ), + input.ompSettings.customModels, + ), + ...(input.skills ? { skills: input.skills } : {}), + ...(input.slashCommands ? { slashCommands: input.slashCommands } : {}), + probe: { + installed: true, + version: input.version, + status: combinedWarning && status === "ready" ? "warning" : status, + auth: input.auth ?? { status: "unknown" }, + ...(message ? { message } : {}), + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), + }, + }); +} + +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.", + }, + }); + } + + // 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( + discoverOmpCommandCatalog(ompSettings, environment ?? process.env).pipe( + Effect.timeoutOption(OMP_RPC_CATALOG_TIMEOUT_MS), + ), + ), + probeOmpUsage(ompSettings, checkedAt, environment), + ], + { concurrency: 2 }, + ); + 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, + }); + } else { + 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; + } + } + // Meta-provider reporting (mirrors OpenCode): tell the user how many + // upstream providers the discovered `provider/model` catalog routes to. + const upstreamCount = countOmpUpstreamProviders(discoveredModels); + return buildOmpProviderSnapshot({ + checkedAt, + ompSettings, + version, + 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.`, + } + : {}), + ...(discoveryWarning ? { discoveryWarning } : {}), + auth: usageProbe.auth, + ...(usageProbe.usageLimits ? { usageLimits: usageProbe.usageLimits } : {}), + }); +}); + +/** + * 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 from the RPC + * catalog probe (or its ACP fallback) 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 caee1981d79f..18bac4cfe6de 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2629,6 +2629,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..847d3b4f53ea --- /dev/null +++ b/apps/server/src/provider/Services/OmpAdapter.ts @@ -0,0 +1,30 @@ +/** + * 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"; + +/** + * 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; +} + +/** + * OmpAdapterShape — per-instance Oh My Pi adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export type OmpAdapterShape = ProviderAdapterShape; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..641cb92616fb 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); }), ), @@ -820,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/server/src/provider/acp/OmpAcpSupport.test.ts b/apps/server/src/provider/acp/OmpAcpSupport.test.ts new file mode 100644 index 000000000000..3f71f1f6708a --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.test.ts @@ -0,0 +1,340 @@ +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"]); + }); + + 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", () => { + 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, + }); + }); + + 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", () => { + 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..1567e4c75ded --- /dev/null +++ b/apps/server/src/provider/acp/OmpAcpSupport.ts @@ -0,0 +1,223 @@ +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 { + 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; + /** + * 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 { + 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, + 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( + ompSettings: OmpAcpRuntimeOmpSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, + options?: { readonly disableTools?: boolean }, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: ompSettings?.binaryPath || "omp", + args: [...ompAcpSpawnArgs(runtimeMode, options)], + 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, + input.disableTools === true ? { disableTools: true } : undefined, + ), + // 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. + ...(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("[")).trim() : trimmed; + return base.length > 0 ? base : undefined; +} 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..bdd40bca93d1 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAnsi.test.ts @@ -0,0 +1,63 @@ +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("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(); + + 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..63ed613da3d5 --- /dev/null +++ b/apps/server/src/provider/acp/OmpAnsi.ts @@ -0,0 +1,63 @@ +/** + * 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. + * 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;:?]*[ -/]*|\](?:(?!\u0007|\u001B\\)[\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, "")); + }, + }; +} 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..7b96a2051f42 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.test.ts @@ -0,0 +1,309 @@ +// 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 NodeFSP from "node:fs/promises"; + +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, TextGenerationError } 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"); + +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 { + 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( + 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); +} + +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; + } + } 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) => { + it.effect("spawns omp acp with tools and auto-approval disabled", () => { + 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 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", "--approval-mode=always-ask", "--no-tools"]]); + + 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("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")( + "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/apps/server/src/textGeneration/OmpTextGeneration.ts b/apps/server/src/textGeneration/OmpTextGeneration.ts new file mode 100644 index 000000000000..10848dacdd09 --- /dev/null +++ b/apps/server/src/textGeneration/OmpTextGeneration.ts @@ -0,0 +1,299 @@ +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 * as EffectAcpErrors from "effect-acp/errors"; +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, + // 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. + enableElicitation: false, + 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") { + 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, + }), + ), + ); + + // 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: "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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a8d1e57e5172..c6249d8a1d8e 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"; @@ -1250,6 +1252,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/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 9d46174d174f..50ea3b8d00b2 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,18 @@ 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("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/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/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index e4d41d53c3a8..cc9cf906c9df 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -758,14 +758,33 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); 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 }) => ( + ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e5a86ff98a3c..c52b033b891e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -100,6 +100,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, @@ -388,9 +389,11 @@ function SidebarThreadTooltip({
candidate.name === composerTrigger.command, + ); + // Only enumerated arguments complete; a free-text hint (`[title]`) has + // nothing to offer and must leave the menu closed. + const options = searchSlashCommandArgumentOptions( + parseSlashCommandArgumentOptions(command?.input?.hint), + composerTrigger.query, + ); + return options.map((option) => ({ + id: `slash-argument:${selectedProvider}:${composerTrigger.command}:${option}`, + type: "slash-argument" as const, + value: option, + label: option, + description: `/${composerTrigger.command} ${option}`, + })); + } if (composerTrigger.kind === "skill") { return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, @@ -3559,6 +3581,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "slash-argument") { + const replacement = `${item.value} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } if (item.type === "provider-slash-command") { if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index 7a59d9054631..16df68245aac 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -98,4 +98,48 @@ describe("ComposerCommandMenu", () => { 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..714cb6c70536 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"; @@ -52,6 +53,14 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "slash-argument"; + /** The literal inserted into the prompt, e.g. `remote` for `/compact`. */ + value: string; + label: string; + description: string; + } | { id: string; type: "skill"; @@ -156,6 +165,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/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..ca1716cdffd8 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, + OmpIcon, } 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")]: OmpIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/chat/slashCommandArguments.test.ts b/apps/web/src/components/chat/slashCommandArguments.test.ts new file mode 100644 index 000000000000..54a705f3edb9 --- /dev/null +++ b/apps/web/src/components/chat/slashCommandArguments.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + parseSlashCommandArgumentOptions, + searchSlashCommandArgumentOptions, +} from "./slashCommandArguments"; + +describe("parseSlashCommandArgumentOptions", () => { + it("reads the choices a required or optional hint enumerates", () => { + expect(parseSlashCommandArgumentOptions("")).toEqual([ + "plan", + "scan", + "status", + "cancel", + ]); + expect(parseSlashCommandArgumentOptions("[on|off|status]")).toEqual(["on", "off", "status"]); + expect(parseSlashCommandArgumentOptions("[soft|remote|snapcompact] [focus]")).toEqual([ + "soft", + "remote", + "snapcompact", + ]); + }); + + it("keeps a choice that has an argument of its own", () => { + expect(parseSlashCommandArgumentOptions("[on|off|status|dump [raw]|configure]")).toEqual([ + "on", + "off", + "status", + "dump", + "configure", + ]); + }); + + it("offers nothing for a placeholder or a single value", () => { + expect(parseSlashCommandArgumentOptions("[title]")).toEqual([]); + expect(parseSlashCommandArgumentOptions("")).toEqual([]); + expect(parseSlashCommandArgumentOptions("[--themes] [path]")).toEqual([]); + expect(parseSlashCommandArgumentOptions(undefined)).toEqual([]); + }); +}); + +describe("searchSlashCommandArgumentOptions", () => { + it("filters by prefix and keeps the hint's order", () => { + const options = ["soft", "remote", "snapcompact"]; + + expect(searchSlashCommandArgumentOptions(options, "s")).toEqual(["soft", "snapcompact"]); + expect(searchSlashCommandArgumentOptions(options, "REM")).toEqual(["remote"]); + expect(searchSlashCommandArgumentOptions(options, "")).toEqual(options); + expect(searchSlashCommandArgumentOptions(options, "zz")).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/slashCommandArguments.ts b/apps/web/src/components/chat/slashCommandArguments.ts new file mode 100644 index 000000000000..5f9c523f90e1 --- /dev/null +++ b/apps/web/src/components/chat/slashCommandArguments.ts @@ -0,0 +1,50 @@ +/** + * Argument completion for provider slash commands. + * + * Agents describe a command's argument in one free-form hint string — + * omp sends `` for `/security` and `[on|off|status]` for + * `/fast`. Where that hint is an enumeration, the composer can offer the + * choices instead of making the user remember them; where it is a + * placeholder (`[title]`, ``), there is nothing to offer and the user + * types freely. + */ + +/** First `<…>` or `[…]` group of a hint: the argument being completed now. */ +const FIRST_HINT_GROUP_PATTERN = /[<[]([^<>[\]]*(?:\[[^\]]*\][^<>[\]]*)*)[>\]]/; +/** A literal a user could type: a word or a flag, not a placeholder phrase. */ +const HINT_LITERAL_PATTERN = /^-{0,2}[A-Za-z][\w-]*$/; + +/** + * Read the choices a hint enumerates, in hint order. Returns an empty list + * for placeholders and for single-choice hints: one "option" is not a menu, + * it is the argument's name. + */ +export function parseSlashCommandArgumentOptions(hint: string | undefined): ReadonlyArray { + const group = hint === undefined ? null : FIRST_HINT_GROUP_PATTERN.exec(hint); + const body = group?.[1]; + if (body === undefined || !body.includes("|")) { + return []; + } + const options: Array = []; + for (const alternative of body.split("|")) { + // `dump [raw]` enumerates `dump`; the nested group is that choice's own + // argument and is completed on the next keystroke, not here. + const literal = alternative.trim().split(/\s+/)[0] ?? ""; + if (HINT_LITERAL_PATTERN.test(literal) && !options.includes(literal)) { + options.push(literal); + } + } + return options.length > 1 ? options : []; +} + +/** Prefix-filter the choices, keeping hint order. Empty query keeps all. */ +export function searchSlashCommandArgumentOptions( + options: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalized = query.trim().toLowerCase(); + if (normalized.length === 0) { + return options; + } + return options.filter((option) => option.toLowerCase().startsWith(normalized)); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..38b1f330e19d 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, OmpIcon, OpenAI } from "../Icons"; import { T3Wordmark } from "../T3Wordmark"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -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 ? ( @@ -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/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..af8b6fba4fb0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,7 +14,7 @@ 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 { ACPRegistryIcon, Gemini, GithubCopilotIcon, type Icon } from "../Icons"; import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; @@ -86,11 +86,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..c36e4aa52ea4 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, + OmpIcon, } 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: OmpIcon, + badgeLabel: "Early Access", + settingsSchema: OmpSettings, + }, { value: ProviderDriverKind.make("antigravity"), label: "Antigravity", diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3f319331bb73..e15840670dca 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -154,11 +154,35 @@ 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("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"; + + 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..24f3a98c492a 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,26 @@ 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] ?? ""; + // 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 + tokenRest.length, + }; + } } const tokenStart = tokenStartForCursor(text, cursor); 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; } 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/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..579194e5a86f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -105,5 +105,44 @@ 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 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`. + Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference handling is documented under [citations](./assistant-citations.md). diff --git a/docs/user/install.md b/docs/user/install.md index a4ed171bd986..d52cb6cdaa46 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. | @@ -118,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/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/docs/user/providers-oh-my-pi.md b/docs/user/providers-oh-my-pi.md new file mode 100644 index 000000000000..e65685cd07c6 --- /dev/null +++ b/docs/user/providers-oh-my-pi.md @@ -0,0 +1,71 @@ +# 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 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 +**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 | +| ------------------------------- | ---------------------------------------------------------------------------- | +| 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 | +| `/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 +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. +- **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. 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. */ 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/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; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ba612edaf26b..7897bc1f87d2 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, @@ -675,9 +676,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 1c53b36037ba..89d6ec211e0e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -814,6 +814,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 @@ -1171,6 +1197,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({}))), @@ -1217,7 +1244,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 @@ -1338,6 +1365,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), @@ -1418,6 +1451,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)) {