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:
7
.changeset/fn-9096-cli-runtime-routing.md
Normal file
7
.changeset/fn-9096-cli-runtime-routing.md
Normal 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.
|
||||||
@@ -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`.
|
`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.
|
`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.
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`.
|
|||||||
|
|
||||||
## The merge gate
|
## 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.
|
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.
|
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. -->
|
<!-- 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. -->
|
<!-- 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.
|
**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-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-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. -->
|
<!-- 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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"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": "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-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:line-count": "node scripts/check-file-line-count.mjs",
|
||||||
"check:routes-modular": "node scripts/check-routes-modular.mjs",
|
"check:routes-modular": "node scripts/check-routes-modular.mjs",
|
||||||
"check:changesets": "node scripts/check-changeset-format.mjs",
|
"check:changesets": "node scripts/check-changeset-format.mjs",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs",
|
"census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs",
|
||||||
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs --strict",
|
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs --strict",
|
||||||
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
"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",
|
"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",
|
"smoke:boot": "node scripts/boot-smoke.mjs",
|
||||||
"local": "node scripts/start-local.mjs",
|
"local": "node scripts/start-local.mjs",
|
||||||
|
|||||||
74
packages/engine/src/__tests__/cli-provider-routing.test.ts
Normal file
74
packages/engine/src/__tests__/cli-provider-routing.test.ts
Normal 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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
it("falls back to default pi runtime when Hermes factory throws", async () => {
|
||||||
const hermesRegistration = createHermesRegistration(() => {
|
const hermesRegistration = createHermesRegistration(() => {
|
||||||
throw new Error("factory exploded");
|
throw new Error("factory exploded");
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ import {
|
|||||||
import type { RunAuditor } from "../util/run-audit.js";
|
import type { RunAuditor } from "../util/run-audit.js";
|
||||||
import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "../auth/auth-storage.js";
|
import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "../auth/auth-storage.js";
|
||||||
import { MockAgentRuntime } from "../providers/mock-provider.js";
|
import { MockAgentRuntime } from "../providers/mock-provider.js";
|
||||||
|
import {
|
||||||
|
applyCliRuntimeOptions,
|
||||||
|
assertExplicitCliRuntimeHint,
|
||||||
|
deriveCliRuntimeHint,
|
||||||
|
dropUnsupportedCliFallback,
|
||||||
|
} from "./cli-provider-routing.js";
|
||||||
|
|
||||||
/** Logger for agent session helpers */
|
/** Logger for agent session helpers */
|
||||||
const sessionLog = createLogger("agent-session");
|
const sessionLog = createLogger("agent-session");
|
||||||
@@ -130,6 +136,11 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions {
|
|||||||
pluginRunner?: PluginRunner;
|
pluginRunner?: PluginRunner;
|
||||||
/** Optional runtime hint from task/agent configuration */
|
/** Optional runtime hint from task/agent configuration */
|
||||||
runtimeHint?: string;
|
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`
|
* Optional run-audit emitter; when provided, a `session:runtime-resolved`
|
||||||
* database event is recorded at resolution time. No-ops when omitted to
|
* database event is recorded at resolution time. No-ops when omitted to
|
||||||
@@ -373,112 +384,6 @@ function stripGrokCliModelProviderPrefix(modelId: string | undefined): string |
|
|||||||
: normalized;
|
: 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). */
|
/** The deferred grok-cli fallback pair a session swaps to when its primary fails (see below). */
|
||||||
interface DeferredGrokCliFallback {
|
interface DeferredGrokCliFallback {
|
||||||
/** Concrete model id for the Grok CLI runtime (provider prefix stripped). */
|
/** Concrete model id for the Grok CLI runtime (provider prefix stripped). */
|
||||||
@@ -878,7 +783,7 @@ export function resolveMergerSessionModel(
|
|||||||
export async function createResolvedAgentSession(
|
export async function createResolvedAgentSession(
|
||||||
options: ResolvedSessionOptions,
|
options: ResolvedSessionOptions,
|
||||||
): Promise<ResolvedSessionResult> {
|
): 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;
|
let credentialResolution: ReturnType<typeof resolveCredentialInstanceRef> | undefined;
|
||||||
if (requestedCredentialInstanceId) {
|
if (requestedCredentialInstanceId) {
|
||||||
try {
|
try {
|
||||||
@@ -980,35 +885,40 @@ export async function createResolvedAgentSession(
|
|||||||
FNXC:GrokCliRouting 2026-07-09-23:05:
|
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.
|
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
|
const autoCliRuntimeHint = !useMockRuntime && !runtimeHint
|
||||||
? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner)
|
? deriveCliRuntimeHint({
|
||||||
|
runtimeOptions,
|
||||||
|
pluginRunner,
|
||||||
|
grokApiKeyVisible: isGrokApiKeyFusionVisible(),
|
||||||
|
cursorCliExecutionSupported,
|
||||||
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const autoOmpRuntimeHint = !useMockRuntime && !runtimeHint
|
const effectiveRuntimeHint = autoCliRuntimeHint ?? runtimeHint;
|
||||||
? deriveOmpRuntimeHint(runtimeOptions, pluginRunner)
|
if (!useMockRuntime) {
|
||||||
: undefined;
|
// Explicit CLI hints with assert-available policy must pre-empt resolveRuntime's pi fallback.
|
||||||
const effectiveRuntimeHint = autoGrokRuntimeHint ?? autoOmpRuntimeHint ?? runtimeHint;
|
assertExplicitCliRuntimeHint({ runtimeHint, runtimeOptions, pluginRunner, cursorCliExecutionSupported });
|
||||||
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 usesAutoGrokRuntime = autoCliRuntimeHint === "grok" && runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID;
|
||||||
/*
|
/*
|
||||||
FNXC:GrokCliRouting 2026-07-22-15:10:
|
FNXC:GrokCliRouting 2026-07-22-15:10:
|
||||||
When only the fallback is grok-cli with no visible key (and the session is not explicitly
|
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
|
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.
|
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)
|
? deferGrokCliFallbackForNoVisibleKey(effectiveRuntimeOptions, pluginRunner)
|
||||||
: { options: effectiveRuntimeOptions, dropped: false as const };
|
: { options: effectiveRuntimeOptions, dropped: false as const };
|
||||||
const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint
|
const droppedCliFallback = dropUnsupportedCliFallback(grokFallbackDeferral.options);
|
||||||
? applyGrokCliNoKeyRuntimeOptions(effectiveRuntimeOptions)
|
const effectiveRuntimeOptionsWithModel = applyCliRuntimeOptions(
|
||||||
: usesOmpRuntime
|
droppedCliFallback.options,
|
||||||
? applyOmpCliRuntimeOptions(grokFallbackDeferral.options)
|
effectiveRuntimeHint,
|
||||||
: grokFallbackDeferral.options;
|
);
|
||||||
const deferredGrokFallback = "deferred" in grokFallbackDeferral ? grokFallbackDeferral.deferred : undefined;
|
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(
|
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.`,
|
`[${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 } : {}),
|
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
|
||||||
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
|
...(autoCliRuntimeHint === "grok" ? { reason: "grok-cli-no-visible-key" } : {}),
|
||||||
...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}),
|
...(autoCliRuntimeHint === "omp" ? { reason: "omp-cli-runtime" } : {}),
|
||||||
...(grokFallbackDeferral.dropped ? { reason: "grok-cli-fallback-dropped-no-visible-key" } : {}),
|
...(grokFallbackDeferral.dropped ? { reason: "grok-cli-fallback-dropped-no-visible-key" } : {}),
|
||||||
...(deferredGrokFallback ? { reason: "grok-cli-fallback-deferred-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) {
|
} catch (err) {
|
||||||
|
|||||||
174
packages/engine/src/agents/cli-provider-routing.ts
Normal file
174
packages/engine/src/agents/cli-provider-routing.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
scripts/check-cli-runtime-routing.mjs
Normal file
26
scripts/check-cli-runtime-routing.mjs
Normal 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);
|
||||||
|
}
|
||||||
84
scripts/lib/cli-runtime-routing-check.mjs
Normal file
84
scripts/lib/cli-runtime-routing-check.mjs
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user