From 1d3f6c198ca01fe87eaaef8882658030dc827ba7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 07:19:29 -0700 Subject: [PATCH] FN-9096: route CLI models through installed runtimes Route every CLI-provider selection through an explicit installed-runtime policy. - Centralize CLI provider classifications, runtime hints, fallback behavior, and actionable missing-runtime errors. - Validate routing coverage statically and add conformance and integration tests for CLI runtime paths. - Document runtime routing behavior and add a published CLI changeset. Files changed: .changeset/fn-9096-cli-runtime-routing.md | 7 + docs/settings-reference.md | 29 +++ docs/testing.md | 6 +- package.json | 6 +- .../src/__tests__/cli-provider-routing.test.ts | 74 ++++++++ .../__tests__/cli-runtime-routing-check.test.ts | 25 +++ .../cli-runtime-routing-conformance.test.ts | 210 +++++++++++++++++++++ .../__tests__/hermes-runtime-integration.test.ts | 28 +++ .../engine/src/agents/agent-session-helpers.ts | 166 ++++------------ packages/engine/src/agents/cli-provider-routing.ts | 174 +++++++++++++++++ scripts/check-cli-runtime-routing.mjs | 26 +++ scripts/lib/cli-runtime-routing-check.mjs | 84 +++++++++ 12 files changed, 701 insertions(+), 134 deletions(-) Fusion-Task-Id: FN-9096 Fusion-Task-Lineage: f9f6a434-b28d-4ebb-816a-53ca75efc2c4 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9096-cli-runtime-routing.md | 7 + docs/settings-reference.md | 29 +++ docs/testing.md | 6 +- package.json | 6 +- .../__tests__/cli-provider-routing.test.ts | 74 ++++++ .../cli-runtime-routing-check.test.ts | 25 +++ .../cli-runtime-routing-conformance.test.ts | 210 ++++++++++++++++++ .../hermes-runtime-integration.test.ts | 28 +++ .../src/agents/agent-session-helpers.ts | 166 ++++---------- .../engine/src/agents/cli-provider-routing.ts | 174 +++++++++++++++ scripts/check-cli-runtime-routing.mjs | 26 +++ scripts/lib/cli-runtime-routing-check.mjs | 84 +++++++ 12 files changed, 701 insertions(+), 134 deletions(-) create mode 100644 .changeset/fn-9096-cli-runtime-routing.md create mode 100644 packages/engine/src/__tests__/cli-provider-routing.test.ts create mode 100644 packages/engine/src/__tests__/cli-runtime-routing-check.test.ts create mode 100644 packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts create mode 100644 packages/engine/src/agents/cli-provider-routing.ts create mode 100644 scripts/check-cli-runtime-routing.mjs create mode 100644 scripts/lib/cli-runtime-routing-check.mjs diff --git a/.changeset/fn-9096-cli-runtime-routing.md b/.changeset/fn-9096-cli-runtime-routing.md new file mode 100644 index 0000000000..2f7cbe3e81 --- /dev/null +++ b/.changeset/fn-9096-cli-runtime-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Route CLI picker models to their installed runtime with actionable failures. +category: fix +dev: Adds cli-provider-routing census, per-path unavailable-runtime policies, and static routing validator. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 498a13ca58..bca92e7087 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1957,3 +1957,32 @@ Settings → Authentication can hold multiple named credential accounts for each `knowledgeGraphDir` is an optional project setting. Its default is `.fusion-knowledge/graph`; keep it outside `.fusion`, which is ignored. The directory is intentionally committable and is refreshed with `fn knowledge-graph build`. `agentMemoryInclusionMode` also controls memory-first pre-steering across triage, execution, review, heartbeat, and agent chat instruction assembly: `full` supplies detailed search-first guidance, `index` supplies a terse form, and `off` suppresses it. + +## CLI provider runtime routing + + + +Fusion routes picker providers through `packages/engine/src/agents/cli-provider-routing.ts`. Operator-installed CLIs remain the plugin README's source of install and upstream provenance. + +| Picker provider | Classification and runtime | Binary / unavailable result | Auto-derive / no guard / explicit hint | Fallback policy | +| --- | --- | --- | --- | --- | +| `pi-claude-cli` | Registry-native pi extension | Managed by pi | n/a / n/a / n/a | none | +| `droid-cli` | Registry-native pi extension | Managed by pi | n/a / n/a / n/a | none | +| `llama-server` | Non-CLI route | Not a bundled CLI runtime | n/a / n/a / n/a | none | +| `omp-cli` | Plugin runtime `omp` | `omp`; missing plugin reports OMP remediation | fail-fast / n/a / assert available | promote to primary | +| `grok-cli` | Plugin runtime `grok` | `grok`; missing plugin fails fast only for no-visible-key primary routing | fail-fast / pinned pi fallback / defer to `resolveRuntime` | defer to runtime | +| `hermes` | Plugin runtime `hermes` | `hermes`; missing plugin names Hermes installation and login remediation | fail-fast / pinned pi fallback / assert available | drop fallback with warning | +| `claude-cli` | Plugin runtime `claude` | Claude Code; missing plugin names Claude install/auth remediation | fail-fast / pinned pi fallback / assert available | drop fallback with warning | +| `cursor-cli` | Withheld unsupported | `cursor-agent`; this build's stub transport fails fast with a Cursor-named plugin remediation | fail-fast / pinned pi fallback / assert available | none | + +A `grok-cli` primary selection without a Fusion-visible `GROK_API_KEY` uses the Grok runtime and fails fast if it is absent. With a visible key, as a fallback-only selection, or under an explicit `runtimeHint: "grok"`, Grok deliberately retains the shipped pi/direct-xAI fallback behavior. This is intentional and differs from OMP's explicit-hint reassertion. + +Cursor support detection does not make the current `TODO(FN-3396)` adapter executable: this engine seam owns the Cursor-named fail-fast result for both support-predicate values. See the [Cursor runtime plugin README](../plugins/fusion-plugin-cursor-runtime/README.md), [Hermes README](../plugins/fusion-plugin-hermes-runtime/README.md), and [Claude README](../plugins/fusion-plugin-claude-runtime/README.md) for operator installation details. + +When adding a picker provider, add a census entry with `autoDerive`, `guardNotApplicable`, and `onExplicitHint` policies. The blocking `check-cli-runtime-routing` static gate parses picker admission and fails on an unclassified, stale, or policy-incomplete entry. diff --git a/docs/testing.md b/docs/testing.md index 0d79a46822..0b69f7a5c5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,14 +6,14 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`. ## The merge gate -CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: independent CLI `--help` and real `fn init` preflights run concurrently, the latter proving a durable `.fusion/project.json` marker, then a real `fn serve` answers `GET /api/health`, all against one isolated home) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test. +CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: independent CLI `--help` and real `fn init` preflights run concurrently, the latter proving a durable `.fusion/project.json` marker, then a real `fn serve` answers `GET /api/health`, all against one isolated home) and `pnpm test:gate`: 12 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test. Set `BOOT_SMOKE_TIMINGS=1` when invoking `pnpm smoke:boot` to print per-attempt help, init, health, and SIGTERM phase timings for diagnosis; the flag is off by default so normal gate output stays concise. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main. Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock. -**Static-validator and lane ordering:** `test:gate:static` declares the 13 canonical, directly runnable read-only validators. `scripts/run-static-gate-checks.mjs` starts them concurrently and waits for **every** result, so zero, one, or multiple policy failures remain fail-closed and observable before tests start. It then starts `engine-core`, `test:pg-gate`, and `test:unit-gate` concurrently; the shell waits for all **three** and returns nonzero if any fail. CI-shape runs only after that successful wait. +**Static-validator and lane ordering:** `test:gate:static` declares the 14 canonical, directly runnable read-only validators. `scripts/run-static-gate-checks.mjs` starts them concurrently and waits for **every** result, so zero, one, or multiple policy failures remain fail-closed and observable before tests start. It then starts `engine-core`, `test:pg-gate`, and `test:unit-gate` concurrently; the shell waits for all **three** and returns nonzero if any fail. CI-shape runs only after that successful wait. **FN-8783 warm result:** The paired W32 protocol recorded in task document `FN-8783/docs` measured the complete-gate median at **15.4s baseline** and **10.2s candidate** across five serialized AB/BA pairs on the same macOS arm64 host (Node 26.3.0, pnpm 10.33.0, identical lockfile). The final engine-core transform-cache profile used one priming run (6.3s), then five warm runs (**5.1, 5.2, 5.1, 5.0, 5.2s; median 5.1s**) versus the pre-cache 6.2s focused engine-core result. The residual full-gate critical path is the unchanged concurrent engine/PG/unit/CI-shape work; task evidence records commands, SHAs, preparation, raw timing order, and coverage counts. @@ -50,7 +50,7 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N -`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command. It first runs the canonical, read-only static validators from root `pretest` — `check-no-nohup`, `check-no-kill-4040`, `check-no-getdatabase`, `check-prerebase-inert`, `check-no-node-only-core-imports-in-dashboard`, `check-pi-versions-pinned`, `check-workspace-package-graph`, `check-no-test-timeout-appeasement`, `check-changeset-format`, `check-routes-modular`, and `check-runtime-skill-loader-drift` (which enforces the Claude/Grok runtime skill loaders' clean rename-diff) — then bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, and runs the existing **boot smoke** once. The static phase invokes each existing validator entry point without update flags, is bounded and fail-fast, and runs **no Vitest or test lane**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs static checks, artifact bootstrap, the CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command. It first runs the canonical, read-only static validators from root `pretest` — `check-no-nohup`, `check-no-kill-4040`, `check-no-getdatabase`, `check-prerebase-inert`, `check-cli-runtime-routing`, `check-no-node-only-core-imports-in-dashboard`, `check-pi-versions-pinned`, `check-workspace-package-graph`, `check-no-test-timeout-appeasement`, `check-changeset-format`, `check-routes-modular`, and `check-runtime-skill-loader-drift` (which enforces the Claude/Grok runtime skill loaders' clean rename-diff) — then bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, and runs the existing **boot smoke** once. The static phase invokes each existing validator entry point without update flags, is bounded and fail-fast, and runs **no Vitest or test lane**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs static checks, artifact bootstrap, the CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). `pnpm check:workspace-package-graph` verifies that every `workspace:` dependency or override in the root importer or a glob-matched workspace manifest resolves to a glob-covered workspace package, and that no package directory under `packages/` or `plugins/` falls outside `pnpm-workspace.yaml` package globs. diff --git a/package.json b/package.json index 33caa2d5f8..2330b516df 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-cli-runtime-routing.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-cli-runtime-routing.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-routes-modular.mjs && node scripts/check-runtime-skill-loader-drift.mjs", "check:line-count": "node scripts/check-file-line-count.mjs", "check:routes-modular": "node scripts/check-routes-modular.mjs", "check:changesets": "node scripts/check-changeset-format.mjs", @@ -31,7 +31,7 @@ "census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs", "check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs --strict", "check:mock-completeness": "node scripts/check-mock-completeness.mjs", - "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && node scripts/check-runtime-skill-loader-drift.mjs", + "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-cli-runtime-routing.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && node scripts/check-runtime-skill-loader-drift.mjs", "test:gate": "node scripts/run-static-gate-checks.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", diff --git a/packages/engine/src/__tests__/cli-provider-routing.test.ts b/packages/engine/src/__tests__/cli-provider-routing.test.ts new file mode 100644 index 0000000000..6ed8cdc2f1 --- /dev/null +++ b/packages/engine/src/__tests__/cli-provider-routing.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CLI_PROVIDER_ROUTING_CENSUS, + applyCliRuntimeOptions, + assertExplicitCliRuntimeHint, + deriveCliRuntimeHint, + dropUnsupportedCliFallback, + stripCliProviderPrefix, +} from "../agents/cli-provider-routing.js"; + +const options = (overrides: Record = {}) => ({ + defaultProvider: undefined, + defaultModelId: undefined, + ...overrides, +}) as never; + +const runner = (available: boolean, throws = false) => ({ + getRuntimeById: vi.fn(() => { + if (throws) throw new Error("lookup failed"); + return available ? {} : undefined; + }), +}); + +describe("CLI provider routing census", () => { + it("declares independent policies and actionable fail-fast ownership", () => { + for (const entry of CLI_PROVIDER_ROUTING_CENSUS) { + expect(entry.autoDerive).toBeTruthy(); + expect(entry.guardNotApplicable).toBeTruthy(); + expect(entry.onExplicitHint).toBeTruthy(); + const policies = [entry.autoDerive, entry.guardNotApplicable, entry.onExplicitHint]; + if (policies.some((policy) => policy !== "fail-fast" && policy !== "assert-available")) { + expect(entry.rationale?.trim()).toBeTruthy(); + } + if (policies.some((policy) => policy === "fail-fast" || policy === "assert-available")) { + expect(Boolean(entry.missingRuntimeError) !== Boolean(entry.externalFailFastOwner)).toBe(true); + } + } + }); + + it.each([ + ["hermes", "hermes"], + ["claude-cli", "claude"], + ["omp-cli", "omp"], + ])("derives %s and rejects absent or throwing runtime lookup", (provider, runtimeId) => { + expect(deriveCliRuntimeHint({ runtimeOptions: options({ defaultProvider: provider }), pluginRunner: runner(true) as never, grokApiKeyVisible: false })).toBe(runtimeId); + for (const pluginRunner of [undefined, runner(false), runner(false, true)]) { + expect(() => deriveCliRuntimeHint({ runtimeOptions: options({ defaultProvider: provider }), pluginRunner: pluginRunner as never, grokApiKeyVisible: false })).toThrow(/runtime plugin/i); + } + }); + + it("keeps Grok's visible-key and explicit-hint fallback policy", () => { + expect(deriveCliRuntimeHint({ runtimeOptions: options({ defaultProvider: "grok-cli" }), pluginRunner: runner(false) as never, grokApiKeyVisible: true })).toBeUndefined(); + expect(() => assertExplicitCliRuntimeHint({ runtimeHint: "grok", runtimeOptions: options({ defaultProvider: "grok-cli" }), pluginRunner: runner(false) as never })).not.toThrow(); + }); + + it("drops unresolved Claude/Hermes fallback without changing the primary", () => { + const result = dropUnsupportedCliFallback(options({ defaultProvider: "openai", defaultModelId: "gpt", fallbackProvider: "hermes", fallbackModelId: "profile" })); + expect(result.droppedProvider).toBe("hermes"); + expect(result.options).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt", fallbackProvider: undefined }); + }); + + it("promotes OMP fallback and strips only its prefix", () => { + expect(stripCliProviderPrefix("omp-cli", "omp-cli/model")).toBe("model"); + expect(stripCliProviderPrefix("omp-cli", "model")).toBe("model"); + expect(stripCliProviderPrefix("omp-cli", " ")).toBe(""); + expect(applyCliRuntimeOptions(options({ defaultProvider: "openai", fallbackProvider: "omp-cli", fallbackModelId: "omp-cli/model" }), "omp")).toMatchObject({ defaultProvider: "omp-cli", defaultModelId: "model", fallbackProvider: undefined }); + }); + + it("uses a provider-named Cursor failure in both injected support states", () => { + for (const _support of [false, true]) { + expect(() => deriveCliRuntimeHint({ runtimeOptions: options({ defaultProvider: "cursor-cli" }), pluginRunner: runner(true) as never, grokApiKeyVisible: false })).toThrow(/Cursor CLI/); + } + }); +}); diff --git a/packages/engine/src/__tests__/cli-runtime-routing-check.test.ts b/packages/engine/src/__tests__/cli-runtime-routing-check.test.ts new file mode 100644 index 0000000000..4a704a37e1 --- /dev/null +++ b/packages/engine/src/__tests__/cli-runtime-routing-check.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { checkCliRuntimeRouting } from "../../../../scripts/lib/cli-runtime-routing-check.mjs"; + +const constants = ['export const FOO_PICKER_PROVIDER_ID = "foo-cli" as const;']; +const route = 'if (enabled) configuredProviders.add(FOO_PICKER_PROVIDER_ID);\nconfiguredProviders.add("native");\nfor (const provider of customProviders) configuredProviders.add(customProviderRegistryKey(provider, customProviders));'; +const entry = (providerId: string, classification = "runtime-routed", extra = "") => `{ providerId: "${providerId}", classification: "${classification}", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "none", missingRuntimeError: buildError ${extra} },`; +const census = (entries = `${entry("foo-cli")}${entry("native", "non-cli")}`) => `export const CLI_PROVIDER_ROUTING_CENSUS = [${entries}];`; +const check = (routeSource = route, censusSource = census(), constantSources = constants) => checkCliRuntimeRouting({ routeSource, censusSource, constantSources }); + +describe("check-cli-runtime-routing", () => { + it("accepts a complete static catalog and ignores named dynamic custom providers", () => expect(check()).toEqual([])); + it("rejects an admitted provider without a census entry", () => expect(check('configuredProviders.add("fake-cli");', census(entry("native", "non-cli")), [])).toContainEqual(expect.stringContaining("fake-cli has no"))); + it("rejects stale non-withheld entries but permits deliberate withheld entries", () => { + expect(check('configuredProviders.add("native");', census(`${entry("native", "non-cli")}${entry("old")}`), [])).toContainEqual(expect.stringContaining("stale census entry old")); + expect(check('configuredProviders.add("native");', census(`${entry("native", "non-cli")}${entry("old", "withheld-unsupported")}`), [])).toEqual([]); + }); + it("rejects missing path policies and builder-less fail-fast entries", () => { + expect(check('configuredProviders.add("native");', census('{ providerId: "native", classification: "non-cli", autoDerive: "fail-fast" },'), [])).toEqual(expect.arrayContaining([expect.stringContaining("missing guardNotApplicable"), expect.stringContaining("neither an error builder")])); + }); + it("rejects unresolved constants, unknown expressions, and empty call sites", () => { + expect(check('configuredProviders.add(MISSING_PICKER_PROVIDER_ID);', census(), [])).toContainEqual(expect.stringContaining("could not resolve")); + expect(check('configuredProviders.add(provider);', census(), [])).toContainEqual(expect.stringContaining("unrecognised")); + expect(check('', census(), [])).toContainEqual(expect.stringContaining("zero")); + }); +}); diff --git a/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts b/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts new file mode 100644 index 0000000000..d816c2b814 --- /dev/null +++ b/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fusionCore from "@fusion/core"; +import { createResolvedAgentSession } from "../agents/agent-session-helpers.js"; +import { CLI_PROVIDER_ROUTING_CENSUS, type CliProviderRouting } from "../agents/cli-provider-routing.js"; +import type { PluginRunner } from "../plugins/plugin-runner.js"; +import type { PluginRuntimeRegistration } from "@fusion/core"; + +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), + wrapToolsWithOutputBudget: vi.fn((tools) => tools), + wrapToolsWithRtkRewrite: vi.fn((tools) => tools), + isRetryableModelSelectionError: vi.fn().mockReturnValue(false), +})); + +function registration(runtimeId: string): { pluginId: string; runtime: PluginRuntimeRegistration } { + return { + pluginId: `fusion-plugin-${runtimeId}-runtime`, + runtime: { + metadata: { runtimeId, name: `${runtimeId} runtime` }, + factory: vi.fn().mockResolvedValue({ + id: runtimeId, + name: `${runtimeId} runtime`, + createSession: vi.fn().mockResolvedValue({ session: { runtimeId } }), + promptWithFallback: vi.fn(), + describeModel: vi.fn().mockReturnValue(`${runtimeId}/model`), + }), + }, + } as PluginRuntimeRegistration; +} + +function runner(runtimeId: string, availability: "available" | "missing" | "throws" = "available"): PluginRunner { + return { + getRuntimeById: vi.fn(() => { + if (availability === "throws") throw new Error("runtime lookup failed"); + return availability === "available" ? registration(runtimeId) : undefined; + }), + createRuntimeContext: vi.fn().mockResolvedValue({ + pluginId: `fusion-plugin-${runtimeId}-runtime`, taskStore: {}, settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, emitEvent: vi.fn(), + }), + } as unknown as PluginRunner; +} + +function options(entry?: CliProviderRouting, overrides: Record = {}) { + return { + sessionPurpose: "executor" as const, + cwd: "/tmp/project", + systemPrompt: "system", + defaultProvider: entry?.providerId, + defaultModelId: entry ? `${entry.providerId}/profile` : "model", + pluginRunner: entry?.runtimeId ? runner(entry.runtimeId) : runner("unused"), + ...overrides, + }; +} + +function grokVisibility(entry: CliProviderRouting, role: "primary" | "fallback" = "primary"): boolean { + return entry.providerId === "grok-cli" && role === "primary" ? false : true; +} + +/** + * FNXC:CliRuntimeRouting 2026-08-15-14:06: + * This is the all-lanes production seam, not a routing-helper unit test. Drive + * every assertion from the census so a catalog classification or per-path + * policy cannot drift without exercising createResolvedAgentSession. + */ +describe("CLI provider routing conformance", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(false); + mockCreateFnAgent.mockReset().mockResolvedValue({ session: { runtimeId: "pi" } }); + }); + + it.each(CLI_PROVIDER_ROUTING_CENSUS.filter((entry) => entry.classification === "runtime-routed"))( + "routes no-hint $providerId selections through $runtimeId when its derive guard applies", + async (entry) => { + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(grokVisibility(entry)); + const result = await createResolvedAgentSession(options(entry)); + expect(result.runtimeId).toBe(entry.runtimeId); + expect(result.runtimeId).not.toBe("pi"); + expect(mockCreateFnAgent).not.toHaveBeenCalled(); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS.filter((entry) => entry.autoDerive === "fail-fast"))( + "applies $providerId auto-derive fail-fast policy for every unavailable lookup state", + async (entry) => { + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(grokVisibility(entry)); + for (const availability of ["missing", "throws"] as const) { + await expect(createResolvedAgentSession(options(entry, { + pluginRunner: entry.runtimeId ? runner(entry.runtimeId, availability) : undefined, + }))).rejects.toThrow(entry.providerId === "cursor-cli" ? /Cursor CLI/ : /runtime plugin/i); + } + await expect(createResolvedAgentSession(options(entry, { pluginRunner: undefined }))).rejects.toThrow( + entry.providerId === "cursor-cli" ? /Cursor CLI/ : /runtime plugin/i, + ); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS.filter((entry) => entry.guardNotApplicable === "pinned-pi-fallback"))( + "preserves $providerId guard-not-applicable pi fallback instead of applying another path policy", + async (entry) => { + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(true); + const result = await createResolvedAgentSession(options(undefined, { + defaultProvider: "openai", + defaultModelId: "primary", + fallbackProvider: entry.providerId, + fallbackModelId: `${entry.providerId}/fallback`, + pluginRunner: runner(entry.runtimeId ?? "unused", "missing"), + })); + expect(result.runtimeId).toBe("pi"); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS.filter((entry) => entry.onExplicitHint !== "n/a"))( + "applies $providerId explicit-hint policy independently from auto-derive", + async (entry) => { + const runtimeHint = entry.runtimeId!; + if (entry.onExplicitHint === "assert-available") { + // Cursor remains withheld even with a registered stub runtime. + if (entry.classification === "withheld-unsupported") { + for (const availability of ["available", "missing", "throws"] as const) { + await expect(createResolvedAgentSession(options(entry, { + runtimeHint, + pluginRunner: runner(runtimeHint, availability), + cursorCliExecutionSupported: true, + }))).rejects.toThrow(/Cursor CLI/); + } + return; + } + const available = await createResolvedAgentSession(options(entry, { + runtimeHint, + pluginRunner: runner(runtimeHint, "available"), + })); + expect(available.runtimeId).toBe(runtimeHint); + for (const availability of ["missing", "throws"] as const) { + await expect(createResolvedAgentSession(options(entry, { + runtimeHint, + pluginRunner: runner(runtimeHint, availability), + }))).rejects.toThrow(/runtime plugin/i); + } + return; + } + const result = await createResolvedAgentSession(options(entry, { + runtimeHint, + pluginRunner: runner(runtimeHint, "missing"), + })); + expect(result.runtimeId).toBe("pi"); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS.filter((entry) => entry.classification === "registry-native" || entry.classification === "non-cli"))( + "leaves $providerId registry/native selection without a CLI runtime hint", + async (entry) => { + const pluginRunner = runner("unused"); + const result = await createResolvedAgentSession(options(entry, { pluginRunner })); + expect(result.runtimeId).toBe("pi"); + expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled(); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS)( + "honors $providerId fallback policy across primary-only, fallback-only, both, and absent roles", + async (entry) => { + vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(true); + const pluginRunner = runner(entry.runtimeId ?? "unused"); + if (entry.classification === "withheld-unsupported") { + await expect(createResolvedAgentSession(options(entry, { pluginRunner }))).rejects.toThrow(/Cursor CLI/); + await expect(createResolvedAgentSession(options(entry, { + fallbackProvider: entry.providerId, fallbackModelId: `${entry.providerId}/fallback`, pluginRunner, + }))).rejects.toThrow(/Cursor CLI/); + } else { + await createResolvedAgentSession(options(entry, { pluginRunner })); + await createResolvedAgentSession(options(entry, { + fallbackProvider: entry.providerId, fallbackModelId: `${entry.providerId}/fallback`, pluginRunner, + })); + } + const fallback = await createResolvedAgentSession(options(undefined, { + defaultProvider: "openai", defaultModelId: "primary", fallbackProvider: entry.providerId, + fallbackModelId: `${entry.providerId}/fallback`, pluginRunner, + })); + const absent = await createResolvedAgentSession(options(undefined, { pluginRunner })); + expect(fallback.runtimeId).toBe(entry.fallbackPolicy === "promote-to-primary" ? "omp" : "pi"); + expect(absent.runtimeId).toBe("pi"); + }, + ); + + it.each(CLI_PROVIDER_ROUTING_CENSUS)("short-circuits $providerId before CLI lookup in test mode", async (entry) => { + const pluginRunner = runner(entry.runtimeId ?? "unused", "throws"); + const result = await createResolvedAgentSession(options(entry, { + pluginRunner, + settings: { testMode: true }, + cursorCliExecutionSupported: true, + })); + expect(result.runtimeId).toBe("mock"); + expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled(); + }); + + it.each([false, true])("injects Cursor support=%s into the withheld Cursor session seam", async (cursorCliExecutionSupported) => { + await expect(createResolvedAgentSession(options( + CLI_PROVIDER_ROUTING_CENSUS.find((entry) => entry.providerId === "cursor-cli"), + { cursorCliExecutionSupported, runtimeHint: "cursor", pluginRunner: runner("cursor") }, + ))).rejects.toThrow(/Cursor CLI/); + }); +}); diff --git a/packages/engine/src/__tests__/hermes-runtime-integration.test.ts b/packages/engine/src/__tests__/hermes-runtime-integration.test.ts index 9564de363c..4a24783ecd 100644 --- a/packages/engine/src/__tests__/hermes-runtime-integration.test.ts +++ b/packages/engine/src/__tests__/hermes-runtime-integration.test.ts @@ -200,6 +200,34 @@ describe("Hermes runtime integration via engine resolution pipeline", () => { })); }); + it("routes a no-hint Hermes picker selection through Hermes before pi model resolution", async () => { + const hermesRegistration = createHermesRegistration(); + const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(hermesRegistration) }); + + const result = await createResolvedAgentSession({ + sessionPurpose: "executor", + pluginRunner, + cwd: "/tmp/project", + systemPrompt: "Use Hermes", + defaultProvider: "hermes", + defaultModelId: "hermes/default", + }); + + expect(result.runtimeId).toBe("hermes"); + expect(mockCreateFnAgent).not.toHaveBeenCalled(); + }); + + it("reports Hermes plugin remediation when a no-hint picker selection lacks its runtime", async () => { + await expect(createResolvedAgentSession({ + sessionPurpose: "executor", + pluginRunner: createMockPluginRunner(), + cwd: "/tmp/project", + systemPrompt: "Use Hermes", + defaultProvider: "hermes", + defaultModelId: "hermes/default", + })).rejects.toThrow(/Hermes CLI/); + }); + it("falls back to default pi runtime when Hermes factory throws", async () => { const hermesRegistration = createHermesRegistration(() => { throw new Error("factory exploded"); diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index ee66689f03..be4e3c2365 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -46,6 +46,12 @@ import { import type { RunAuditor } from "../util/run-audit.js"; import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "../auth/auth-storage.js"; import { MockAgentRuntime } from "../providers/mock-provider.js"; +import { + applyCliRuntimeOptions, + assertExplicitCliRuntimeHint, + deriveCliRuntimeHint, + dropUnsupportedCliFallback, +} from "./cli-provider-routing.js"; /** Logger for agent session helpers */ const sessionLog = createLogger("agent-session"); @@ -130,6 +136,11 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions { pluginRunner?: PluginRunner; /** Optional runtime hint from task/agent configuration */ runtimeHint?: string; + /** + * Injected Cursor support status for routing conformance. Production leaves it + * unset while the bundled Cursor adapter remains a non-executable stub. + */ + cursorCliExecutionSupported?: boolean; /** * Optional run-audit emitter; when provided, a `session:runtime-resolved` * database event is recorded at resolution time. No-ops when omitted to @@ -373,112 +384,6 @@ 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. " - + "Install and enable the Grok CLI runtime plugin, or set GROK_API_KEY to use the direct xAI endpoint.", - ); -} - -/* -FNXC:GrokCliRouting 2026-07-22-14:30: -The no-visible-key Grok CLI auto-derive fires only when grok-cli is the PRIMARY provider. -FN-7758 used to fire on a grok-cli FALLBACK too and promoted it to primary up front, which -silently replaced a healthy configured primary (e.g. planning openai-codex/gpt-5.6-sol ran -as grok/grok-4.5 on every triage session because the workflow's planningFallback was -grok-cli). A fallback must never preempt a primary that has not failed; the fallback-only -case is handled by dropGrokCliFallbackForNoVisibleKey below. -*/ -function deriveGrokRuntimeHintForNoVisibleKey( - runtimeOptions: AgentRuntimeOptions, - pluginRunner: PluginRunner | undefined, -): string | undefined { - if (runtimeOptions.defaultProvider !== GROK_CLI_PROVIDER_ID) return undefined; - if (isGrokApiKeyFusionVisible()) return undefined; - try { - if (pluginRunner?.getRuntimeById("grok")) return "grok"; - } catch { - throw buildMissingGrokRuntimeError(); - } - throw buildMissingGrokRuntimeError(); -} - -function applyGrokCliNoKeyRuntimeOptions( - runtimeOptions: AgentRuntimeOptions, -): AgentRuntimeOptions { - if (runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID) { - return { - ...runtimeOptions, - defaultModelId: stripGrokCliModelProviderPrefix(runtimeOptions.defaultModelId), - }; - } - - return runtimeOptions; -} - /** The deferred grok-cli fallback pair a session swaps to when its primary fails (see below). */ interface DeferredGrokCliFallback { /** Concrete model id for the Grok CLI runtime (provider prefix stripped). */ @@ -878,7 +783,7 @@ export function resolveMergerSessionModel( export async function createResolvedAgentSession( options: ResolvedSessionOptions, ): Promise { - const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options; + const { sessionPurpose, pluginRunner, runtimeHint, cursorCliExecutionSupported, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options; let credentialResolution: ReturnType | undefined; if (requestedCredentialInstanceId) { try { @@ -980,35 +885,40 @@ export async function createResolvedAgentSession( FNXC:GrokCliRouting 2026-07-09-23:05: FN-7761 closes the packaged serve/daemon/dashboard gap: if grok-cli is selected and no Fusion-visible key exists, this seam must never silently fall through to the key-requiring pi/openai-completions runtime when the Grok plugin was not pre-installed. The hosts eagerly install/load the bundled runtime; if that genuinely fails, throw an operator-actionable error naming the two supported remediations. */ - const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint - ? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner) + const autoCliRuntimeHint = !useMockRuntime && !runtimeHint + ? deriveCliRuntimeHint({ + runtimeOptions, + pluginRunner, + grokApiKeyVisible: isGrokApiKeyFusionVisible(), + cursorCliExecutionSupported, + }) : undefined; - 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 effectiveRuntimeHint = autoCliRuntimeHint ?? runtimeHint; + if (!useMockRuntime) { + // Explicit CLI hints with assert-available policy must pre-empt resolveRuntime's pi fallback. + assertExplicitCliRuntimeHint({ runtimeHint, runtimeOptions, pluginRunner, cursorCliExecutionSupported }); } + const usesAutoGrokRuntime = autoCliRuntimeHint === "grok" && runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID; /* FNXC:GrokCliRouting 2026-07-22-15:10: When only the fallback is grok-cli with no visible key (and the session is not explicitly hinted onto the Grok runtime), withhold the pair from the primary runtime and arm a prompt-time swap to the Grok CLI runtime instead of preempting the configured primary. */ - const grokFallbackDeferral = !useMockRuntime && !autoGrokRuntimeHint && effectiveRuntimeHint !== "grok" + const grokFallbackDeferral = !useMockRuntime && !usesAutoGrokRuntime && effectiveRuntimeHint !== "grok" ? deferGrokCliFallbackForNoVisibleKey(effectiveRuntimeOptions, pluginRunner) : { options: effectiveRuntimeOptions, dropped: false as const }; - const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint - ? applyGrokCliNoKeyRuntimeOptions(effectiveRuntimeOptions) - : usesOmpRuntime - ? applyOmpCliRuntimeOptions(grokFallbackDeferral.options) - : grokFallbackDeferral.options; + const droppedCliFallback = dropUnsupportedCliFallback(grokFallbackDeferral.options); + const effectiveRuntimeOptionsWithModel = applyCliRuntimeOptions( + droppedCliFallback.options, + effectiveRuntimeHint, + ); const deferredGrokFallback = "deferred" in grokFallbackDeferral ? grokFallbackDeferral.deferred : undefined; - if (grokFallbackDeferral.dropped) { + if (droppedCliFallback.droppedProvider) { + sessionLog.warn( + `[${sessionPurpose}] configured ${droppedCliFallback.droppedProvider} fallback "${runtimeOptions.fallbackModelId ?? "unknown"}" dropped: primary "${runtimeOptions.defaultProvider}/${runtimeOptions.defaultModelId}" is unchanged because the fallback requires its own CLI runtime.`, + ); + } else if (grokFallbackDeferral.dropped) { sessionLog.warn( `[${sessionPurpose}] configured grok-cli fallback "${runtimeOptions.fallbackModelId ?? "unknown"}" dropped: no Fusion-visible GROK_API_KEY and the Grok CLI runtime plugin is unavailable; primary "${runtimeOptions.defaultProvider}/${runtimeOptions.defaultModelId}" is unchanged. Install/enable the Grok CLI runtime plugin or set GROK_API_KEY.`, ); @@ -1131,11 +1041,11 @@ export async function createResolvedAgentSession( } : {}), ...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}), - ...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}), - ...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}), + ...(autoCliRuntimeHint === "grok" ? { reason: "grok-cli-no-visible-key" } : {}), + ...(autoCliRuntimeHint === "omp" ? { reason: "omp-cli-runtime" } : {}), ...(grokFallbackDeferral.dropped ? { reason: "grok-cli-fallback-dropped-no-visible-key" } : {}), ...(deferredGrokFallback ? { reason: "grok-cli-fallback-deferred-no-visible-key" } : {}), - ...(!autoGrokRuntimeHint && !autoOmpRuntimeHint && !grokFallbackDeferral.dropped && !deferredGrokFallback && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}), + ...(!autoCliRuntimeHint && !grokFallbackDeferral.dropped && !deferredGrokFallback && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}), }, }); } catch (err) { diff --git a/packages/engine/src/agents/cli-provider-routing.ts b/packages/engine/src/agents/cli-provider-routing.ts new file mode 100644 index 0000000000..2740118515 --- /dev/null +++ b/packages/engine/src/agents/cli-provider-routing.ts @@ -0,0 +1,174 @@ +import type { AgentRuntimeOptions } from "./agent-runtime.js"; +import type { PluginRunner } from "../plugins/plugin-runner.js"; + +export type CliProviderClassification = "registry-native" | "runtime-routed" | "non-cli" | "withheld-unsupported"; +export type CliPathPolicy = "fail-fast" | "pinned-pi-fallback" | "assert-available" | "defer-to-resolve-runtime" | "n/a"; +export type CliFallbackPolicy = "promote-to-primary" | "defer-to-runtime" | "drop-with-warning" | "none"; + +export interface CliProviderRouting { + providerId: string; + classification: CliProviderClassification; + runtimeId?: string; + autoDerive: CliPathPolicy; + guardNotApplicable: CliPathPolicy; + onExplicitHint: CliPathPolicy; + fallbackPolicy: CliFallbackPolicy; + rationale?: string; + missingRuntimeError?: () => Error; + externalFailFastOwner?: string; +} + +function unavailable(runtime: string, remediation: string): Error { + return new Error(`${runtime} models require the bundled ${runtime} runtime plugin. ${remediation}`); +} + +export 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).", + ); +} + +export function buildMissingGrokRuntimeError(): Error { + return new Error( + "Grok CLI models require the bundled Grok CLI runtime when no Fusion-visible GROK_API_KEY is set. " + + "Install and enable the Grok CLI runtime plugin, or set GROK_API_KEY to use the direct xAI endpoint.", + ); +} + +export function buildMissingHermesRuntimeError(): Error { + return unavailable("Hermes CLI", "Install and enable the Hermes runtime plugin (fusion-plugin-hermes-runtime), install `hermes`, and run `hermes login`."); +} + +export function buildMissingClaudeRuntimeError(): Error { + return unavailable("Claude Code CLI", "Install and enable the Claude runtime plugin (fusion-plugin-claude-runtime), install Claude Code, and authenticate with `claude`."); +} + +export function buildMissingCursorRuntimeError(cursorCliExecutionSupported = false): Error { + const transportDetail = cursorCliExecutionSupported + ? "The host reports Cursor CLI support, but this build has no executable Cursor CLI transport." + : "This build has no executable Cursor CLI transport."; + return unavailable("Cursor CLI", `Install and enable the Cursor runtime plugin (fusion-plugin-cursor-runtime). ${transportDetail}`); +} + +/* +FNXC:CliRuntimeRouting 2026-08-15-13:51: +Picker rows are selectable execution contracts. The census keeps every catalog +provider's route and each unavailable-runtime path explicit so a missing plugin +cannot fall through to pi's unrelated custom-provider guidance after worktree +creation. Grok deliberately differs by path: no-key primary routing fails fast, +while visible-key, fallback-only, and explicit-hint paths retain their shipped +pi fallback. Factory failures remain observations because read-only +resolveRuntime decides them after this seam confirms a registration exists. + +Cursor Branch C applies on this revision: the support predicate is absent and +the adapter remains TODO(FN-3396), so both injectable support states fail fast +at this seam rather than claiming the stub can execute prompts. Completeness is +checked by a repository static guard instead of importing dashboard from engine. +*/ +export const CLI_PROVIDER_ROUTING_CENSUS: readonly CliProviderRouting[] = [ + { providerId: "pi-claude-cli", classification: "registry-native", autoDerive: "n/a", guardNotApplicable: "n/a", onExplicitHint: "n/a", fallbackPolicy: "none", rationale: "Vendored pi extension resolves through pi's registry." }, + { providerId: "droid-cli", classification: "registry-native", autoDerive: "n/a", guardNotApplicable: "n/a", onExplicitHint: "n/a", fallbackPolicy: "none", rationale: "Vendored pi extension resolves through pi's registry." }, + { providerId: "llama-server", classification: "non-cli", autoDerive: "n/a", guardNotApplicable: "n/a", onExplicitHint: "n/a", fallbackPolicy: "none", rationale: "Llama server is not a bundled CLI runtime route." }, + { providerId: "omp-cli", classification: "runtime-routed", runtimeId: "omp", autoDerive: "fail-fast", guardNotApplicable: "n/a", onExplicitHint: "assert-available", fallbackPolicy: "promote-to-primary", missingRuntimeError: buildMissingOmpRuntimeError, rationale: "OMP's guard covers both primary and fallback selections; runtime factory errors remain resolveRuntime observations." }, + { providerId: "grok-cli", classification: "runtime-routed", runtimeId: "grok", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "defer-to-resolve-runtime", fallbackPolicy: "defer-to-runtime", missingRuntimeError: buildMissingGrokRuntimeError, rationale: "Visible-key, fallback-only, and explicit-hint paths intentionally preserve the shipped direct xAI/pi fallback." }, + { providerId: "hermes", classification: "runtime-routed", runtimeId: "hermes", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "drop-with-warning", missingRuntimeError: buildMissingHermesRuntimeError, rationale: "Fallback-only Hermes cannot be resolved by a healthy primary pi runtime." }, + { providerId: "claude-cli", classification: "runtime-routed", runtimeId: "claude", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "drop-with-warning", missingRuntimeError: buildMissingClaudeRuntimeError, rationale: "Fallback-only Claude CLI cannot be resolved by a healthy primary pi runtime." }, + { providerId: "cursor-cli", classification: "withheld-unsupported", runtimeId: "cursor", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "none", missingRuntimeError: buildMissingCursorRuntimeError, rationale: "The registered adapter is a TODO(FN-3396) transport stub; support detection cannot make it executable." }, +] as const; + +export function getCliProviderRouting(providerId: string | undefined): CliProviderRouting | undefined { + return CLI_PROVIDER_ROUTING_CENSUS.find((entry) => entry.providerId === providerId); +} + +function isAvailable(pluginRunner: PluginRunner | undefined, runtimeId: string): boolean { + try { + return Boolean(pluginRunner?.getRuntimeById(runtimeId)); + } catch { + return false; + } +} + +function assertAvailable(entry: CliProviderRouting, pluginRunner: PluginRunner | undefined): void { + if (!entry.runtimeId || !isAvailable(pluginRunner, entry.runtimeId)) { + throw entry.missingRuntimeError?.() ?? new Error(`${entry.providerId} runtime is unavailable.`); + } +} + +export function stripCliProviderPrefix(providerId: string, modelId: string | undefined): string | undefined { + const normalized = modelId?.trim(); + if (!normalized) return normalized; + const prefix = `${providerId}/`; + return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized; +} + +export function deriveCliRuntimeHint(args: { + runtimeOptions: AgentRuntimeOptions; + pluginRunner: PluginRunner | undefined; + grokApiKeyVisible: boolean; + cursorCliExecutionSupported?: boolean; +}): string | undefined { + const { runtimeOptions, pluginRunner, grokApiKeyVisible, cursorCliExecutionSupported } = args; + const primary = getCliProviderRouting(runtimeOptions.defaultProvider); + const omp = getCliProviderRouting("omp-cli")!; + if (runtimeOptions.defaultProvider === "omp-cli" || runtimeOptions.fallbackProvider === "omp-cli") { + assertAvailable(omp, pluginRunner); + return "omp"; + } + if (!primary || primary.classification === "registry-native" || primary.classification === "non-cli") return undefined; + if (primary.classification === "withheld-unsupported") { + // FNXC:CliRuntimeRouting 2026-08-15-14:06: The Cursor adapter is a transport stub, so a registered plugin must not make either injected support state executable. + if (primary.providerId === "cursor-cli") throw buildMissingCursorRuntimeError(cursorCliExecutionSupported); + throw primary.missingRuntimeError?.() ?? new Error(`${primary.providerId} is unavailable.`); + } + if (primary.providerId === "grok-cli" && grokApiKeyVisible) return undefined; + assertAvailable(primary, pluginRunner); + return primary.runtimeId; +} + +export function assertExplicitCliRuntimeHint(args: { + runtimeHint: string | undefined; + runtimeOptions: AgentRuntimeOptions; + pluginRunner: PluginRunner | undefined; + cursorCliExecutionSupported?: boolean; +}): void { + if (!args.runtimeHint) return; + const selected = getCliProviderRouting(args.runtimeOptions.defaultProvider) + ?? (args.runtimeOptions.fallbackProvider === "omp-cli" ? getCliProviderRouting("omp-cli") : undefined); + if (!selected || selected.runtimeId !== args.runtimeHint || selected.onExplicitHint !== "assert-available") return; + // FNXC:CliRuntimeRouting 2026-08-15-14:06: Explicit hints cannot bypass a withheld Cursor adapter merely because its non-executable stub registration exists. + if (selected.classification === "withheld-unsupported") { + throw selected.providerId === "cursor-cli" + ? buildMissingCursorRuntimeError(args.cursorCliExecutionSupported) + : selected.missingRuntimeError?.() ?? new Error(`${selected.providerId} is unavailable.`); + } + assertAvailable(selected, args.pluginRunner); +} + +export function applyCliRuntimeOptions(runtimeOptions: AgentRuntimeOptions, runtimeHint: string | undefined): AgentRuntimeOptions { + if (runtimeHint === "omp" && runtimeOptions.fallbackProvider === "omp-cli" && runtimeOptions.defaultProvider !== "omp-cli") { + return { + ...runtimeOptions, + defaultProvider: "omp-cli", + defaultModelId: stripCliProviderPrefix("omp-cli", runtimeOptions.fallbackModelId), + defaultThinkingLevel: runtimeOptions.fallbackThinkingLevel ?? runtimeOptions.defaultThinkingLevel, + fallbackProvider: undefined, + fallbackModelId: undefined, + fallbackThinkingLevel: undefined, + }; + } + const entry = getCliProviderRouting(runtimeOptions.defaultProvider); + if (entry && entry.runtimeId === runtimeHint && entry.classification === "runtime-routed") { + return { ...runtimeOptions, defaultModelId: stripCliProviderPrefix(entry.providerId, runtimeOptions.defaultModelId) }; + } + return runtimeOptions; +} + +export function dropUnsupportedCliFallback(runtimeOptions: AgentRuntimeOptions): { options: AgentRuntimeOptions; droppedProvider?: string } { + const entry = getCliProviderRouting(runtimeOptions.fallbackProvider); + if (!entry || entry.fallbackPolicy !== "drop-with-warning" || runtimeOptions.defaultProvider === entry.providerId) return { options: runtimeOptions }; + return { + options: { ...runtimeOptions, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined }, + droppedProvider: entry.providerId, + }; +} diff --git a/scripts/check-cli-runtime-routing.mjs b/scripts/check-cli-runtime-routing.mjs new file mode 100644 index 0000000000..ed38e5d81e --- /dev/null +++ b/scripts/check-cli-runtime-routing.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { checkCliRuntimeRouting } from "./lib/cli-runtime-routing-check.mjs"; + +const route = "packages/dashboard/src/routes/register-model-routes.ts"; +const cachesDir = "packages/dashboard/src"; +const census = "packages/engine/src/agents/cli-provider-routing.ts"; +try { + const constantSources = readdirSync(cachesDir) + .filter((name) => name.endsWith("model-cache.ts")) + .map((name) => readFileSync(join(cachesDir, name), "utf8")); + const violations = checkCliRuntimeRouting({ + routeSource: readFileSync(route, "utf8"), + censusSource: readFileSync(census, "utf8"), + constantSources, + }); + if (violations.length) { + console.error("check-cli-runtime-routing: FAILED\n" + violations.map((item) => `- ${item}`).join("\n")); + process.exit(1); + } + console.log("check-cli-runtime-routing: ok"); +} catch (error) { + console.error(`check-cli-runtime-routing: could not inspect required source: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/scripts/lib/cli-runtime-routing-check.mjs b/scripts/lib/cli-runtime-routing-check.mjs new file mode 100644 index 0000000000..c6f5c6ed30 --- /dev/null +++ b/scripts/lib/cli-runtime-routing-check.mjs @@ -0,0 +1,84 @@ +/* +FNXC:CliRuntimeRouting 2026-08-15-13:51: +The dashboard owns picker admission and engine deliberately cannot import it. +Parse the explicit `configuredProviders.add(...)` forms instead, so a new +selectable provider cannot become executable only through pi by accident. +Unrecognised syntax is a violation: this guard must fail closed, not quietly +skip a catalog form it no longer understands. +*/ + +const ADD = /configuredProviders\.add\(([^\n;]+)\)/g; +const STRING = /^\s*["']([^"']+)["']\s*$/; +const PICKER = /^\s*([A-Z][A-Z0-9_]*_PICKER_PROVIDER_ID)\s*$/; +const DYNAMIC = /^\s*customProviderRegistryKey\(/; + +function censusEntries(source) { + const entries = []; + const object = /\{\s*providerId:\s*["']([^"']+)["']([\s\S]*?)\}/g; + for (const match of source.matchAll(object)) { + const body = match[2]; + const value = (name) => new RegExp(`${name}:\\s*["']([^"']+)["']`).exec(body)?.[1]; + entries.push({ + providerId: match[1], + classification: value("classification"), + autoDerive: value("autoDerive"), + guardNotApplicable: value("guardNotApplicable"), + onExplicitHint: value("onExplicitHint"), + hasBuilder: /missingRuntimeError\s*:/.test(body), + externalFailFastOwner: value("externalFailFastOwner"), + }); + } + return entries; +} + +function constantsFromSources(sources) { + const constants = new Map(); + for (const source of sources) { + for (const match of source.matchAll(/export const ([A-Z][A-Z0-9_]*_PICKER_PROVIDER_ID)\s*=\s*["']([^"']+)["']\s+as const/g)) { + constants.set(match[1], match[2]); + } + } + return constants; +} + +/** @param {{routeSource:string,censusSource:string,constantSources?:string[]}} input */ +export function checkCliRuntimeRouting(input) { + const violations = []; + const constants = constantsFromSources(input.constantSources ?? []); + const admitted = new Set(); + let calls = 0; + for (const match of input.routeSource.matchAll(ADD)) { + calls += 1; + const expression = match[1].trim(); + const literal = STRING.exec(expression)?.[1]; + if (literal) { admitted.add(literal); continue; } + const name = PICKER.exec(expression)?.[1]; + if (name) { + const provider = constants.get(name); + if (!provider) violations.push(`could not resolve ${name} to a picker provider string literal`); + else admitted.add(provider); + continue; + } + if (DYNAMIC.test(expression)) continue; + violations.push(`unrecognised configuredProviders.add expression: ${expression}`); + } + if (calls === 0) violations.push("zero configuredProviders.add call sites found"); + + const census = censusEntries(input.censusSource); + if (census.length === 0) violations.push("CLI provider routing census is empty or unparseable"); + const byProvider = new Map(census.map((entry) => [entry.providerId, entry])); + for (const provider of admitted) { + if (!byProvider.has(provider)) violations.push(`admitted provider ${provider} has no CLI routing census entry (valid classifications: registry-native, runtime-routed, non-cli, withheld-unsupported)`); + } + for (const entry of census) { + if (!admitted.has(entry.providerId) && entry.classification !== "withheld-unsupported") violations.push(`stale census entry ${entry.providerId} is no longer admitted by the catalog`); + for (const field of ["autoDerive", "guardNotApplicable", "onExplicitHint"]) { + if (!entry[field]) violations.push(`census entry ${entry.providerId} is missing ${field} policy`); + } + const policies = [entry.autoDerive, entry.guardNotApplicable, entry.onExplicitHint]; + if (policies.includes("fail-fast") && !entry.hasBuilder && !entry.externalFailFastOwner) { + violations.push(`fail-fast census entry ${entry.providerId} has neither an error builder nor externalFailFastOwner`); + } + } + return violations; +}