diff --git a/.changeset/fn-8262-omp-model-routing.md b/.changeset/fn-8262-omp-model-routing.md new file mode 100644 index 0000000000..a95b124154 --- /dev/null +++ b/.changeset/fn-8262-omp-model-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Oh My Pi (omp) model selections now run via the OMP ACP runtime instead of failing. +category: fix +dev: agent-session-helpers auto-derives runtime hint "omp" for omp-cli primary/fallback selections (mirrors the Grok CLI no-visible-key seam); short-circuits under test mode/mock provider, validates an explicit "omp" hint against runtime availability, and prevents the "not found in the pi model registry" hard-fail. Throws an actionable error when the OMP runtime plugin is unavailable. diff --git a/packages/engine/src/__tests__/agent-session-helpers-omp-routing.test.ts b/packages/engine/src/__tests__/agent-session-helpers-omp-routing.test.ts new file mode 100644 index 0000000000..105d06d46c --- /dev/null +++ b/packages/engine/src/__tests__/agent-session-helpers-omp-routing.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fusionCore from "@fusion/core"; +import { createResolvedAgentSession } from "../agent-session-helpers.js"; +import { MOCK_PROVIDER_ID } from "../providers/mock-provider.js"; + +const mockCreateFnAgent = vi.hoisted(() => vi.fn()); + +vi.mock("../pi.js", () => ({ + createFnAgent: mockCreateFnAgent, + promptWithFallback: vi.fn().mockResolvedValue(undefined), + describeModel: vi.fn().mockReturnValue("pi/default"), + wrapToolsWithActionGate: vi.fn((tools) => tools), + wrapToolsWithPermanentAgentGating: vi.fn((tools) => tools), + wrapToolsWithRtkRewrite: vi.fn((tools) => tools), +})); + +/* +FNXC:OmpAcp 2026-07-18-09:00: +FN-8262 regression coverage keeps `omp-cli` selections out of pi's model registry: primary/fallback selections use the ACP runtime, unavailable runtime states provide the plugin remediation, and mock/test mode makes no OMP lookup. +*/ +function makeOmpPluginRunnerStub(options?: { includeOmp?: boolean; includeOther?: boolean }) { + const createSession = vi.fn().mockResolvedValue({ + session: { model: "omp/MiniMax-M2.5", messages: [], dispose: vi.fn() }, + }); + const ompRegistration = { + pluginId: "fusion-plugin-omp-runtime", + runtime: { + metadata: { runtimeId: "omp", name: "OMP Runtime" }, + factory: vi.fn().mockResolvedValue({ + id: "omp", + name: "OMP Runtime", + createSession, + promptWithFallback: vi.fn(), + describeModel: vi.fn(() => "omp/MiniMax-M2.5"), + }), + }, + }; + const otherRegistration = { + pluginId: "other-runtime", + runtime: { + metadata: { runtimeId: "other", name: "Other Runtime" }, + factory: vi.fn().mockResolvedValue({ + id: "other", + name: "Other Runtime", + createSession, + promptWithFallback: vi.fn(), + describeModel: vi.fn(() => "other/model"), + }), + }, + }; + const getRuntimeById = vi.fn((runtimeId: string) => { + if (runtimeId === "omp" && options?.includeOmp !== false) return ompRegistration; + if (runtimeId === "other" && options?.includeOther) return otherRegistration; + return undefined; + }); + return { + pluginRunner: { + getRuntimeById, + createRuntimeContext: vi.fn().mockResolvedValue({ + pluginId: "fusion-plugin-omp-runtime", + taskStore: {}, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + }), + }, + getRuntimeById, + createSession, + }; +} + +function sessionOptions(overrides: Record = {}) { + return { + sessionPurpose: "executor" as const, + cwd: "/tmp/project", + systemPrompt: "system", + ...overrides, + }; +} + +describe("createResolvedAgentSession OMP runtime routing", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockCreateFnAgent.mockReset().mockResolvedValue({ + session: { model: "pi/default", messages: [], dispose: vi.fn() }, + }); + }); + + it.each(["MiniMax-M2.5", "omp-cli/MiniMax-M2.5"])( + "routes primary omp-cli model %s through OMP ACP instead of pi registry", + async (defaultModelId) => { + const { pluginRunner, getRuntimeById, createSession } = makeOmpPluginRunnerStub(); + const audit = { database: vi.fn().mockResolvedValue(undefined) }; + + const result = await createResolvedAgentSession(sessionOptions({ + pluginRunner: pluginRunner as never, + runAuditor: audit as never, + defaultProvider: "omp-cli", + defaultModelId, + })); + + expect(result.runtimeId).toBe("omp"); + expect(getRuntimeById).toHaveBeenCalledWith("omp"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + })); + expect(mockCreateFnAgent).not.toHaveBeenCalled(); + expect(audit.database).toHaveBeenCalledWith(expect.objectContaining({ + type: "session:runtime-resolved", + target: "omp", + metadata: expect.objectContaining({ reason: "omp-cli-runtime" }), + })); + }, + ); + + it("promotes an omp-cli fallback pair and thinking level into the OMP session", async () => { + const { pluginRunner, createSession } = makeOmpPluginRunnerStub(); + + const result = await createResolvedAgentSession(sessionOptions({ + pluginRunner: pluginRunner as never, + defaultProvider: undefined, + defaultModelId: undefined, + fallbackProvider: "omp-cli", + fallbackModelId: "omp-cli/MiniMax-M2.5", + fallbackThinkingLevel: "high", + })); + + expect(result.runtimeId).toBe("omp"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + defaultThinkingLevel: "high", + fallbackProvider: undefined, + fallbackModelId: undefined, + fallbackThinkingLevel: undefined, + })); + }); + + it.each([ + ["an absent runtime registration", makeOmpPluginRunnerStub({ includeOmp: false }).pluginRunner], + ["no pluginRunner", undefined], + ])("reports OMP plugin remediation for %s", async (_label, pluginRunner) => { + const promise = createResolvedAgentSession(sessionOptions({ + pluginRunner: pluginRunner as never, + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + })); + + await expect(promise).rejects.toThrow(/OMP runtime plugin/); + await expect(promise).rejects.not.toThrow(/not found in the pi model registry/); + }); + + it("reports OMP plugin remediation for an unavailable explicit omp hint", async () => { + const { pluginRunner } = makeOmpPluginRunnerStub({ includeOmp: false }); + + await expect(createResolvedAgentSession(sessionOptions({ + pluginRunner: pluginRunner as never, + runtimeHint: "omp", + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + }))).rejects.toThrow(/OMP runtime plugin/); + }); + + it("uses mock without OMP lookup in test mode or when mock is primary", async () => { + const testModeRunner = makeOmpPluginRunnerStub(); + const testModeResult = await createResolvedAgentSession(sessionOptions({ + pluginRunner: testModeRunner.pluginRunner as never, + settings: { testMode: true } as never, + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + })); + expect(testModeResult.runtimeId).toBe(MOCK_PROVIDER_ID); + expect(testModeRunner.getRuntimeById).not.toHaveBeenCalled(); + + const mockRunner = makeOmpPluginRunnerStub(); + const mockResult = await createResolvedAgentSession(sessionOptions({ + pluginRunner: mockRunner.pluginRunner as never, + defaultProvider: MOCK_PROVIDER_ID, + defaultModelId: "scripted", + fallbackProvider: "omp-cli", + fallbackModelId: "MiniMax-M2.5", + })); + expect(mockResult.runtimeId).toBe(MOCK_PROVIDER_ID); + expect(mockRunner.getRuntimeById).not.toHaveBeenCalled(); + + const configuredMockRunner = makeOmpPluginRunnerStub(); + const configuredMockResult = await createResolvedAgentSession(sessionOptions({ + pluginRunner: configuredMockRunner.pluginRunner as never, + settings: { defaultProvider: MOCK_PROVIDER_ID } as never, + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + })); + expect(configuredMockResult.runtimeId).toBe(MOCK_PROVIDER_ID); + expect(configuredMockRunner.getRuntimeById).not.toHaveBeenCalled(); + }); + + it("respects a non-OMP explicit runtime hint and leaves Grok routing independent", async () => { + const { pluginRunner, getRuntimeById } = makeOmpPluginRunnerStub({ includeOther: true }); + const explicitResult = await createResolvedAgentSession(sessionOptions({ + pluginRunner: pluginRunner as never, + runtimeHint: "other", + defaultProvider: "omp-cli", + defaultModelId: "MiniMax-M2.5", + })); + expect(explicitResult.runtimeId).toBe("other"); + expect(getRuntimeById).not.toHaveBeenCalledWith("omp"); + + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(true); + const grokRunner = makeOmpPluginRunnerStub(); + await createResolvedAgentSession(sessionOptions({ + pluginRunner: grokRunner.pluginRunner as never, + defaultProvider: "grok-cli", + defaultModelId: "grok-4.5", + })); + expect(grokRunner.getRuntimeById).not.toHaveBeenCalledWith("omp"); + }); +}); diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 974a6e4e3d..0886dcf9e0 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -355,6 +355,69 @@ function stripGrokCliModelProviderPrefix(modelId: string | undefined): string | : normalized; } +const OMP_CLI_PROVIDER_ID = "omp-cli"; + +function isOmpCliSelection(runtimeOptions: AgentRuntimeOptions): boolean { + return runtimeOptions.defaultProvider === OMP_CLI_PROVIDER_ID + || runtimeOptions.fallbackProvider === OMP_CLI_PROVIDER_ID; +} + +function stripOmpCliModelProviderPrefix(modelId: string | undefined): string | undefined { + const normalized = modelId?.trim(); + if (!normalized) return normalized; + const ompCliPrefix = `${OMP_CLI_PROVIDER_ID}/`; + return normalized.startsWith(ompCliPrefix) + ? normalized.slice(ompCliPrefix.length) + : normalized; +} + +/* +FNXC:OmpAcp 2026-07-18-09:00: +FN-8262: `omp-cli/*` models are dynamically discovered from `omp models` and are never registered in pi's execution registry, so route primary and fallback selections to the bundled `omp` ACP runtime before pi resolves a model. Test mode must short-circuit to mock without looking up OMP; an unavailable explicit `runtimeHint: "omp"` must report the OMP plugin remediation rather than pi's misleading model-not-found error. +*/ +function buildMissingOmpRuntimeError(): Error { + return new Error( + "Oh My Pi (omp) models require the bundled OMP runtime plugin. " + + "Install and enable the OMP Runtime plugin (fusion-plugin-omp-runtime) and ensure the `omp` binary is installed and authenticated (`omp acp`, credentials under ~/.omp).", + ); +} + +function deriveOmpRuntimeHint( + runtimeOptions: AgentRuntimeOptions, + pluginRunner: PluginRunner | undefined, +): string | undefined { + if (!isOmpCliSelection(runtimeOptions)) return undefined; + try { + if (pluginRunner?.getRuntimeById("omp")) return "omp"; + } catch { + throw buildMissingOmpRuntimeError(); + } + throw buildMissingOmpRuntimeError(); +} + +function applyOmpCliRuntimeOptions(runtimeOptions: AgentRuntimeOptions): AgentRuntimeOptions { + if (runtimeOptions.defaultProvider === OMP_CLI_PROVIDER_ID) { + return { + ...runtimeOptions, + defaultModelId: stripOmpCliModelProviderPrefix(runtimeOptions.defaultModelId), + }; + } + + if (runtimeOptions.fallbackProvider === OMP_CLI_PROVIDER_ID) { + return { + ...runtimeOptions, + defaultProvider: runtimeOptions.fallbackProvider, + defaultModelId: stripOmpCliModelProviderPrefix(runtimeOptions.fallbackModelId), + defaultThinkingLevel: runtimeOptions.fallbackThinkingLevel ?? runtimeOptions.defaultThinkingLevel, + fallbackProvider: undefined, + fallbackModelId: undefined, + fallbackThinkingLevel: undefined, + }; + } + + return runtimeOptions; +} + function buildMissingGrokRuntimeError(): Error { return new Error( "Grok CLI models require the bundled Grok CLI runtime when no Fusion-visible GROK_API_KEY is set. " @@ -646,7 +709,9 @@ export async function createResolvedAgentSession( // FNXC:McpConfig 2026-06-25-22:06: // createResolvedAgentSession is the common lane helper for executor, reviewer, validator, workflow model-node, summarization, and merger-adjacent paths that pass MCP through this seam. Preserve `mcpServers` verbatim here; runtime-resolution/pi own support-gated forwarding and content-free skip logging. - const useMockRuntime = isMockProviderId(runtimeOptions.defaultProvider); + const testModeActive = settings ? isTestModeActive(settings) : false; + const mockProviderActive = isMockProviderId(runtimeOptions.defaultProvider); + const useMockRuntime = mockProviderActive || testModeActive; const effectiveRuntimeOptions = useMockRuntime ? { ...runtimeOptions, @@ -676,10 +741,21 @@ export async function createResolvedAgentSession( const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint ? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner) : undefined; - const effectiveRuntimeHint = autoGrokRuntimeHint ?? runtimeHint; + const autoOmpRuntimeHint = !useMockRuntime && !runtimeHint + ? deriveOmpRuntimeHint(runtimeOptions, pluginRunner) + : undefined; + const effectiveRuntimeHint = autoGrokRuntimeHint ?? autoOmpRuntimeHint ?? runtimeHint; + const usesOmpRuntime = effectiveRuntimeHint === "omp" && isOmpCliSelection(runtimeOptions); + if (usesOmpRuntime) { + // resolveRuntime intentionally falls back to pi for an unavailable hint; OMP + // selections must fail here instead so pi never attempts registry resolution. + deriveOmpRuntimeHint(runtimeOptions, pluginRunner); + } const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint ? applyGrokCliNoKeyRuntimeOptions(effectiveRuntimeOptions) - : effectiveRuntimeOptions; + : usesOmpRuntime + ? applyOmpCliRuntimeOptions(effectiveRuntimeOptions) + : effectiveRuntimeOptions; const resolved = useMockRuntime ? { @@ -722,8 +798,6 @@ export async function createResolvedAgentSession( }; const result = await resolved.runtime.createSession(sessionCreateOptions); - const testModeActive = settings ? isTestModeActive(settings) : false; - const mockProviderActive = isMockProviderId(runtimeOptions.defaultProvider); const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId); const runtimeBuiltInFallbackModel = noModelResolved ? resolved.runtime.describeModel(result.session) : undefined; if (noModelResolved) { @@ -751,7 +825,8 @@ export async function createResolvedAgentSession( ...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}), ...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}), ...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}), - ...(!autoGrokRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}), + ...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}), + ...(!autoGrokRuntimeHint && !autoOmpRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}), }, }); } catch (err) {