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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 07:19:29 -07:00
parent 284feeaf11
commit 1d3f6c198c
12 changed files with 701 additions and 134 deletions

View File

@@ -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.

View File

@@ -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
<!--
FNXC:CliRuntimeRouting 2026-08-15-13:51:
A model picker row must name an executable route, not merely a discovery source.
The routing census makes plugin absence actionable before pi can misidentify a
CLI selection as a Custom Provider configuration problem; each path is declared
independently because Grok intentionally has different direct-key behavior.
-->
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.

View File

@@ -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.
<!-- FNXC:MergeGatePerformance 2026-08-04-15:44: FN-8783 removes only independent static-validator scheduling overhead. The exact policy inventory, all 22 engine files, every PG/unit canary, their nonzero propagation, and CI-shape-after-success remain blocking; a timing win that weakens any of those contracts is not accepted. -->
**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.
<!-- FNXC:MergeGatePerformance 2026-08-04-16:09: FN-8783 keeps engine-core's forks, worker budget, parallelism, bundle rebuild, and all 22 files while caching only Vitest transform artifacts between warm runs. This is never a test-result cache: every assertion and mock boundary remains evaluated for each invocation. -->
**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
<!-- FNXC:TestInfrastructure 2026-06-25-00:00: verify:fast is the opt-in test-free verification path. docs/testing.md observes the broad test gate caught no recalled real bugs while consuming ~70% of shipping time in flake triage; typecheck+build+boot-smoke gives deterministic, flake-free signal without running tests. It changes no default — pnpm test, the merge gate, and CI are untouched; the full suite stays available and runs non-blocking. -->
<!-- FNXC:TestInfrastructure 2026-06-26-00:49: verify:fast must bootstrap missing workspace dist artifacts and build @runfusion/fusion even when the CLI package is not in the changed-package set because package builds and the boot smoke invoke source-checkout wrappers that require dist outputs in fresh worktrees. -->
<!-- FNXC:TestInfrastructure 2026-07-22-12:00: Cheap deterministic policy gates must fail before verify:fast's expensive work. Read canonical package.json pretest commands and invoke their validator entry points directly so test-free verification and the merge gate cannot drift. -->
`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.

View File

@@ -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",

View File

@@ -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<string, unknown> = {}) => ({
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/);
}
});
});

View File

@@ -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"));
});
});

View File

@@ -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<string, unknown> = {}) {
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/);
});
});

View File

@@ -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");

View File

@@ -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<ResolvedSessionResult> {
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<typeof resolveCredentialInstanceRef> | 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) {

View File

@@ -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,
};
}

View File

@@ -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);
}

View File

@@ -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;
}