FN-7753: route grok-cli execution through the grok CLI when no Fusion-visible GROK_API_KEY resolves
Route grok-cli model selections through the grok CLI runtime when no Fusion-visible GROK_API_KEY is available. - Add read-only isGrokApiKeyFusionVisible() in packages/core/src/grok-provider.ts, refactored to share user-settings-file reading with hydrateGrokApiKeyFromUserSettings without mutating process.env or logging key material. - In packages/engine/src/agent-session-helpers.ts, auto-derive the existing "grok" runtimeHint when defaultProvider is grok-cli, no key is Fusion-visible, and the grok plugin runtime is registered; explicit runtime hints and mock/test-mode routing remain unchanged, and the provider-qualified model prefix is stripped before handoff. - Normalize provider-qualified model ids (grok-cli/<id>, grok/<id>) in the grok-runtime plugin's runtime-adapter and CLI stream spawn so the concrete model reaches `grok --model`, with the historical grok/default fallback preserved for the no-model path. - Update docs (grok-cli-contract.md, settings-reference.md, plugin README) and add/extend tests covering the new fallback behavior, model normalization, and CLI streaming. - Add changeset fn-7753-grok-cli-no-key-fallback.md (patch, fix). Files changed: .changeset/fn-7753-grok-cli-no-key-fallback.md | 7 ++ docs/grok-cli-contract.md | 83 ++++++++++------ docs/settings-reference.md | 6 +- .../__tests__/grok-provider-user-settings.test.ts | 46 +++++++++ packages/core/src/grok-provider.ts | 39 +++++++- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + .../src/__tests__/grok-runtime-routing.test.ts | 107 +++++++++++++++++++-- packages/engine/src/agent-session-helpers.ts | 52 +++++++++- plugins/fusion-plugin-grok-runtime/README.md | 46 +++++---- .../src/__tests__/cli-stream.test.ts | 70 ++++++++++++++ .../src/__tests__/runtime-adapter.test.ts | 28 ++++++ .../fusion-plugin-grok-runtime/src/cli-stream.ts | 6 ++ .../src/runtime-adapter.ts | 24 ++++- 14 files changed, 443 insertions(+), 73 deletions(-) Fusion-Task-Id: FN-7753 Fusion-Task-Lineage: 30ef7265-1ba9-47fd-8c4e-87b02f6a1d78 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7753-grok-cli-no-key-fallback.md
Normal file
7
.changeset/fn-7753-grok-cli-no-key-fallback.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Grok CLI models now run via the grok CLI when no Fusion-visible API key is set.
|
||||
category: fix
|
||||
dev: createResolvedAgentSession (packages/engine/src/agent-session-helpers.ts) auto-derives runtimeHint "grok" when defaultProvider is grok-cli, no GROK_API_KEY is Fusion-visible (new read-only isGrokApiKeyFusionVisible in packages/core/src/grok-provider.ts), and the Grok runtime is registered; the selected model is passed to the CLI via a new --model option on spawnGrokStream. Explicit runtime hints and the key-visible direct-endpoint default are unchanged. Closes the deferred FN-7722/FN-7725 follow-up.
|
||||
@@ -134,7 +134,7 @@ Notes:
|
||||
`GrokCliProviderCard.tsx`) is out of scope for this task and is not
|
||||
modified here.
|
||||
|
||||
## Wiring (resolved — FN-7725)
|
||||
## Wiring (resolved — FN-7725, extended by FN-7753)
|
||||
|
||||
<!--
|
||||
FNXC:GrokCli 2026-07-09-00:00:
|
||||
@@ -148,14 +148,33 @@ default and unchanged.
|
||||
|
||||
**Decision: option (a) — formalize, document, and test the existing agent
|
||||
Runtime-mode picker path. Do NOT add a new settings toggle (option (b)).**
|
||||
FN-7753 later closed the deferred no-key model-selection fallback without adding
|
||||
that rejected UI toggle: the session seam derives the same runtime hint
|
||||
automatically only when the direct endpoint cannot work because no Fusion-visible
|
||||
GROK_API_KEY resolves.
|
||||
|
||||
**Trigger:** an agent's `runtimeConfig.runtimeHint === "grok"`, set today via
|
||||
the dashboard's agent **Runtime Source → Runtime** picker
|
||||
**Explicit trigger:** an agent's `runtimeConfig.runtimeHint === "grok"`, set today
|
||||
via the dashboard's agent **Runtime Source → Runtime** picker
|
||||
(`NewAgentDialog.tsx` / `AgentDetailView.tsx`), which is populated from
|
||||
`GET /api/plugins/runtimes` (already generic — surfaces every registered
|
||||
plugin runtime, including the bundled Grok Runtime plugin's `runtimeId:
|
||||
"grok"`, with no Grok-specific code required).
|
||||
|
||||
**Automatic no-key fallback (FN-7753):** when `createResolvedAgentSession()` sees
|
||||
all of the following, it derives the same effective `runtimeHint: "grok"` before
|
||||
calling `resolveRuntime()`:
|
||||
|
||||
1. no explicit runtime hint was supplied (explicit hints, including `"pi"`,
|
||||
always win);
|
||||
2. the resolved execution provider is `grok-cli`;
|
||||
3. Fusion cannot see a non-empty `GROK_API_KEY` either in the environment or in
|
||||
`~/.grok/user-settings.json`'s `apiKey` field; and
|
||||
4. the bundled Grok Runtime plugin has registered runtime id `"grok"`.
|
||||
|
||||
If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains
|
||||
the default. If the Grok runtime is not registered, Fusion leaves the session on
|
||||
the existing PI/direct path rather than inventing a separate routing mode.
|
||||
|
||||
**Exact seam:** `packages/engine/src/agent-session-helpers.ts`'s
|
||||
`extractRuntimeHint(runtimeConfig)` reads that hint from the assigned agent's
|
||||
`runtimeConfig` and threads it, as `runtimeHint`, into
|
||||
@@ -188,24 +207,21 @@ additive change," formalizing + testing + documenting the already-working
|
||||
path is lower risk and closes the actual gap (an *exercised* path, not just
|
||||
an implemented adapter) without adding new user-facing config surface.
|
||||
|
||||
**Known limitation (by design, unchanged by this task):** Runtime-mode is
|
||||
model-agnostic — `NewAgentDialog.tsx`/`AgentDetailView.tsx` clear the `model`
|
||||
field when Runtime mode is selected (`model: runtimeMode === "runtime" ? ""
|
||||
: ...`), so `GrokRuntimeAdapter.createSession()` never receives a
|
||||
`defaultModelId` from this path and always falls back to `"grok/default"`. A
|
||||
specific `grok-cli/*` model choice is therefore not preserved when routing
|
||||
via Runtime-mode. Preserving model selection through the CLI runtime would
|
||||
require option (b) (or an equivalent); it is filed as a follow-up task only
|
||||
if genuinely warranted (see Follow-ups below), not implemented here.
|
||||
**Model plumbing (FN-7753):** for the automatic no-key fallback, the selected
|
||||
`grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId`,
|
||||
normalized by stripping a leading `grok-cli/` (or `grok/`) prefix, and passed to
|
||||
the CLI as `grok --model <id>` alongside `--prompt` and `--format json`.
|
||||
Runtime-mode remains model-agnostic when chosen explicitly from the dashboard;
|
||||
that no-model path still uses the adapter's historical `"grok/default"` session
|
||||
fallback and omits `--model`.
|
||||
|
||||
**Why the direct xAI endpoint stays default:** nothing in this task changes
|
||||
what a `grok-cli/*` **model** selection does — it continues to route through
|
||||
the direct xAI OpenAI-compatible endpoint (FN-7711/FN-7714,
|
||||
`packages/core/src/grok-provider.ts`, `packages/engine/src/pi.ts`), which
|
||||
this task does not touch. The CLI-routed path is reached *only* by the
|
||||
separate, explicit agent Runtime-mode choice — an opt-in, additive,
|
||||
fully-reversible path (nothing sets the hint unless an operator explicitly
|
||||
picks Runtime mode for that agent).
|
||||
**Why the direct xAI endpoint stays default:** a `grok-cli/*` **model** selection
|
||||
continues to route through the direct xAI OpenAI-compatible endpoint
|
||||
(FN-7711/FN-7714, `packages/core/src/grok-provider.ts`, `packages/engine/src/pi.ts`)
|
||||
whenever Fusion can see a key. FN-7753 changes only the failing no-visible-key
|
||||
case, where the direct path would otherwise hard-fail even though the installed
|
||||
CLI may be authenticated by a source Fusion cannot inspect (project `.env`,
|
||||
`grok -k`, OAuth/login token store, sandbox secrets, etc.).
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -228,17 +244,18 @@ Rationale:
|
||||
tool-call/break-early bridging as a documented follow-up (the Droid
|
||||
adapter's much larger `provider.ts` is the effort ceiling, not the target
|
||||
shape).
|
||||
- It is fully reversible: the adapter is only reachable via
|
||||
`runtimeHint === "grok"`, which nothing sets today, so landing it carries
|
||||
no behavioral change to any exercised path.
|
||||
- It is fully reversible: the adapter is reachable via either an explicit
|
||||
`runtimeHint === "grok"` or FN-7753's narrow no-visible-key `grok-cli`
|
||||
fallback. The direct endpoint remains the key-visible default.
|
||||
|
||||
## What stays unchanged
|
||||
|
||||
- The **direct xAI OpenAI-compatible streaming path** (base URL
|
||||
`https://api.x.ai/v1`, api type `openai-completions`, `GROK_API_KEY`
|
||||
sourced per FN-7711/FN-7714) remains the default, exercised Grok execution
|
||||
path. This task does not touch `packages/core/src/grok-provider.ts` or
|
||||
`packages/engine/src/pi.ts`.
|
||||
sourced per FN-7711/FN-7714) remains the default when a Fusion-visible key
|
||||
exists. FN-7753 adds a read-only key-visibility check in
|
||||
`packages/core/src/grok-provider.ts` and derives CLI routing only when no
|
||||
such key is visible and the Grok runtime is registered.
|
||||
- FN-7716's probe/auth-readiness surface (`probe.ts`,
|
||||
`register-auth-routes.ts`, `GrokCliProviderCard.tsx`) is untouched by this
|
||||
task.
|
||||
@@ -265,8 +282,10 @@ See the task's `fn_task_create` calls (linked from FN-7722) for:
|
||||
`close`/`error`, unchanged from FN-7722. See
|
||||
`plugins/fusion-plugin-grok-runtime/README.md`'s "Tool execution
|
||||
bridging (FN-7724)" section.
|
||||
3. (Filed by FN-7725, if warranted) Preserving a specific `grok-cli/*` model
|
||||
selection when routing through the CLI runtime (Runtime-mode is currently
|
||||
model-agnostic — see "Known limitation" in "Wiring" above). This is the
|
||||
deferred option (b) shape; only file it if a genuine operator need
|
||||
surfaces, per the task's Decision guidance.
|
||||
3. ~~Preserving a specific `grok-cli/*` model selection when routing through
|
||||
the CLI runtime~~ — **closed by FN-7753** for the automatic no-visible-key
|
||||
fallback: `createResolvedAgentSession()` derives runtime hint `"grok"` only
|
||||
when no explicit hint is set, provider is `grok-cli`, no Fusion-visible
|
||||
`GROK_API_KEY`/user-settings `apiKey` resolves, and runtime id `"grok"` is
|
||||
registered; the selected model is passed to the CLI via `--model <id>`.
|
||||
Explicit Runtime-mode remains model-agnostic by design.
|
||||
|
||||
@@ -104,7 +104,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
|
||||
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
|
||||
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. |
|
||||
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
|
||||
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok is API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`) — there is no OAuth/session login flow. |
|
||||
| `useGrokCli` | `boolean` | `undefined` | Enables the `grok-cli` provider in model pickers after Grok CLI status validation. Toggle from Settings → Authentication. Grok's direct xAI endpoint uses API-key auth (`GROK_API_KEY` env var or `~/.grok/user-settings.json` `apiKey`); when a `grok-cli/*` execution model has no Fusion-visible key, Fusion falls back to the `grok` CLI runtime if registered so the CLI can use its own auth store. |
|
||||
| `grokCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Grok CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `grok` on PATH. Invalid non-empty saves are rejected with bounded diagnostics. |
|
||||
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
|
||||
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
|
||||
@@ -969,13 +969,13 @@ When the planning lane has neither `planningFallback*` nor a global `fallback*`
|
||||
|
||||
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
|
||||
|
||||
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation).
|
||||
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below). If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation). When no Fusion-visible key resolves and the Grok Runtime plugin has registered runtime id `grok`, `createResolvedAgentSession()` derives that runtime automatically for `grok-cli` execution and passes the selected model to the CLI via `--model <id>`; explicit runtime hints and key-visible direct-endpoint routing take precedence.
|
||||
|
||||
When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers.
|
||||
|
||||
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.
|
||||
|
||||
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok is API-key auth: the Settings card's status text guides operators to `GROK_API_KEY` or `~/.grok/user-settings.json` when the binary is available but no key is configured.
|
||||
When the Grok Runtime plugin (`fusion-plugin-grok-runtime`) is installed and the `useGrokCli` toggle is enabled (Settings → Authentication), Grok CLI-discovered models (`grok models`) are surfaced additively in `/api/models` under the `grok-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `grok` on every request; a missing/failed/unavailable Grok CLI binary simply yields zero `grok-cli` rows without affecting other providers. Disabling `useGrokCli` hides all `grok-cli` rows. Unlike Cursor (OAuth/session auth), Grok direct-endpoint auth is API-key based, but CLI-routed execution lets the `grok` binary use any auth source it supports; the Settings card still surfaces Fusion-visible key detection only as an informational hint.
|
||||
|
||||
The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) are additively surfaced under the `openai-codex` provider (FN-7745/FN-7754, mirroring the Anthropic/Z.ai supplemental-merge pattern above) so they appear both in dashboard `/api/models` and the engine/pi `createFnAgent` registry-seeding surface whenever `openai-codex` is configured — deduped against any pinned pi-ai catalog row that already carries one of the ids.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GROK_CLI_PROVIDER_ID,
|
||||
GROK_PROVIDER_REGISTRATION,
|
||||
hydrateGrokApiKeyFromUserSettings,
|
||||
isGrokApiKeyFusionVisible,
|
||||
registerBuiltInGrokProvider,
|
||||
} from "../grok-provider.js";
|
||||
|
||||
@@ -29,6 +30,51 @@ mirrors probe.test.ts's fallback/precedence/fail-soft matrix so the $GROK_API_KE
|
||||
reference resolves from ~/.grok/user-settings.json when the env var is unset, without ever
|
||||
overwriting an operator-provided env value or throwing on a missing/malformed file.
|
||||
*/
|
||||
describe("isGrokApiKeyFusionVisible", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.GROK_API_KEY;
|
||||
});
|
||||
|
||||
it("returns true when env is set and does not read the settings file", () => {
|
||||
process.env.GROK_API_KEY = " xai-from-env ";
|
||||
|
||||
expect(isGrokApiKeyFusionVisible()).toBe(true);
|
||||
expect(readFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true when env is unset and user settings has a non-empty apiKey without mutating env", () => {
|
||||
vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify({ apiKey: " xai-from-file " }));
|
||||
|
||||
expect(isGrokApiKeyFusionVisible()).toBe(true);
|
||||
expect(process.env.GROK_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns false for a missing settings file without throwing or mutating env", () => {
|
||||
const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
||||
vi.mocked(readFileSync).mockImplementationOnce(() => {
|
||||
throw enoent;
|
||||
});
|
||||
|
||||
expect(() => isGrokApiKeyFusionVisible()).not.toThrow();
|
||||
expect(isGrokApiKeyFusionVisible()).toBe(false);
|
||||
expect(process.env.GROK_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns false for empty, malformed, or keyless settings without throwing or mutating env", () => {
|
||||
vi.mocked(readFileSync)
|
||||
.mockReturnValueOnce(JSON.stringify({ apiKey: " " }))
|
||||
.mockReturnValueOnce("not json")
|
||||
.mockReturnValueOnce(JSON.stringify({}));
|
||||
|
||||
expect(isGrokApiKeyFusionVisible()).toBe(false);
|
||||
expect(() => isGrokApiKeyFusionVisible()).not.toThrow();
|
||||
expect(isGrokApiKeyFusionVisible()).toBe(false);
|
||||
expect(process.env.GROK_API_KEY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateGrokApiKeyFromUserSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -192,6 +192,37 @@ function cloneGrokProviderRegistration(config: GrokProviderRegistration): GrokPr
|
||||
* `~/.grok/user-settings.json` path), kept synchronous here because
|
||||
* `registerBuiltInGrokProvider` itself is synchronous.
|
||||
*/
|
||||
function readGrokUserSettingsApiKey(): string | undefined {
|
||||
const settingsPath = join(homedir(), ".grok", "user-settings.json");
|
||||
const raw = readFileSync(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { apiKey?: unknown };
|
||||
return typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0
|
||||
? parsed.apiKey.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProviderAuth 2026-07-09-00:00:
|
||||
* FN-7753 needs a read-only, synchronous key-visibility predicate before runtime
|
||||
* resolution chooses between the direct xAI endpoint and the `grok` CLI runtime.
|
||||
* Mirror hydrateGrokApiKeyFromUserSettings/probeGrokApiKeyPresence exactly:
|
||||
* non-empty `GROK_API_KEY` env wins; otherwise inspect `~/.grok/user-settings.json`
|
||||
* for a non-empty `apiKey`; missing, unreadable, malformed, or keyless files are
|
||||
* ordinary false results. Never mutate process.env and never log key material.
|
||||
*/
|
||||
export function isGrokApiKeyFusionVisible(): boolean {
|
||||
const envKey = process.env.GROK_API_KEY;
|
||||
if (typeof envKey === "string" && envKey.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return readGrokUserSettingsApiKey() !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateGrokApiKeyFromUserSettings(
|
||||
logWarning: (message: string) => void = () => {},
|
||||
): void {
|
||||
@@ -202,11 +233,9 @@ export function hydrateGrokApiKeyFromUserSettings(
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsPath = join(homedir(), ".grok", "user-settings.json");
|
||||
const raw = readFileSync(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { apiKey?: unknown };
|
||||
if (typeof parsed?.apiKey === "string" && parsed.apiKey.trim().length > 0) {
|
||||
process.env.GROK_API_KEY = parsed.apiKey.trim();
|
||||
const apiKey = readGrokUserSettingsApiKey();
|
||||
if (apiKey) {
|
||||
process.env.GROK_API_KEY = apiKey;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail-soft: a missing (ENOENT), malformed, or unreadable settings file must never throw
|
||||
|
||||
@@ -98,6 +98,7 @@ export type { ZaiProviderRegistration } from "./zai-provider.js";
|
||||
export {
|
||||
GROK_CLI_PROVIDER_ID,
|
||||
GROK_PROVIDER_REGISTRATION,
|
||||
isGrokApiKeyFusionVisible,
|
||||
mergeBuiltInGrokProviderModels,
|
||||
registerBuiltInGrokProvider,
|
||||
} from "./grok-provider.js";
|
||||
|
||||
@@ -65,6 +65,7 @@ export type { ZaiProviderRegistration } from "./zai-provider.js";
|
||||
export {
|
||||
GROK_CLI_PROVIDER_ID,
|
||||
GROK_PROVIDER_REGISTRATION,
|
||||
isGrokApiKeyFusionVisible,
|
||||
mergeBuiltInGrokProviderModels,
|
||||
registerBuiltInGrokProvider,
|
||||
} from "./grok-provider.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRunner } from "../plugin-runner.js";
|
||||
import type { PluginRuntimeRegistration } from "@fusion/core";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import { resolveRuntime } from "../runtime-resolution.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint } from "../agent-session-helpers.js";
|
||||
|
||||
@@ -48,7 +49,7 @@ function grokRuntimeAdapterModulePath(): string {
|
||||
|
||||
type GrokRuntimeAdapterCtor = new (options?: {
|
||||
binary?: string;
|
||||
spawn?: (binary: string, prompt: string, options?: { cwd?: string; signal?: AbortSignal }) => unknown;
|
||||
spawn?: (binary: string, prompt: string, options?: { cwd?: string; model?: string; signal?: AbortSignal }) => unknown;
|
||||
}) => {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -110,6 +111,7 @@ async function createGrokRegistration(
|
||||
describe("Grok CLI runtime routing (FN-7725)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(fusionCore, "isGrokApiKeyFusionVisible").mockReturnValue(true);
|
||||
mockCreateFnAgent.mockResolvedValue({
|
||||
session: { runtime: "pi", prompt: vi.fn() },
|
||||
sessionFile: "/tmp/pi.session.json",
|
||||
@@ -229,28 +231,117 @@ describe("Grok CLI runtime routing (FN-7725)", () => {
|
||||
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not route through Grok for a different runtime hint (e.g. a grok-cli/* model selection stays on the default pi runtime)", async () => {
|
||||
it("auto-routes a grok-cli model selection to the Grok runtime when no Fusion-visible key exists", async () => {
|
||||
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
|
||||
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
|
||||
const grokRegistration = await createGrokRegistration(spawn);
|
||||
const pluginRunner = createMockPluginRunner({
|
||||
getRuntimeById: vi.fn().mockReturnValue(undefined),
|
||||
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
|
||||
});
|
||||
const audit = { database: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// A grok-cli/* MODEL selection (Built-in Model mode) never sets
|
||||
// runtimeHint at all -- it is passed as runtimeConfig.model, which
|
||||
// extractRuntimeHint does not read. Simulate that shape explicitly.
|
||||
const runtimeHint = extractRuntimeHint({ model: "grok-cli/grok-4" });
|
||||
const runtimeHint = extractRuntimeHint({ model: "grok-cli/grok-4.5" });
|
||||
expect(runtimeHint).toBeUndefined();
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
runtimeHint,
|
||||
pluginRunner,
|
||||
runAuditor: audit as never,
|
||||
cwd: "/tmp/project",
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-cli/grok-4.5",
|
||||
systemPrompt: "model-selection-only",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("grok");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
expect(mockCreateFnAgent).not.toHaveBeenCalled();
|
||||
expect(result.session).toMatchObject({ model: "grok-4.5" });
|
||||
expect(audit.database).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "session:runtime-resolved",
|
||||
target: "grok",
|
||||
metadata: expect.objectContaining({
|
||||
runtimeHint: "grok",
|
||||
reason: "grok-cli-no-visible-key",
|
||||
provider: "grok-cli",
|
||||
modelId: "grok-cli/grok-4.5",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps grok-cli on the direct pi runtime when a Fusion-visible key exists", async () => {
|
||||
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(true);
|
||||
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
|
||||
const grokRegistration = await createGrokRegistration(spawn);
|
||||
const pluginRunner = createMockPluginRunner({
|
||||
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
|
||||
});
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner,
|
||||
cwd: "/tmp/project",
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
systemPrompt: "direct-endpoint-default",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps grok-cli on pi when no key is visible but the Grok runtime is not registered", async () => {
|
||||
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
|
||||
const pluginRunner = createMockPluginRunner({
|
||||
getRuntimeById: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner,
|
||||
cwd: "/tmp/project",
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
systemPrompt: "no-runtime-registered",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
}));
|
||||
});
|
||||
|
||||
it("honors explicit runtime hints over the no-key grok-cli auto-derivation", async () => {
|
||||
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
|
||||
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
|
||||
const grokRegistration = await createGrokRegistration(spawn);
|
||||
const getRuntimeById = vi.fn((runtimeId: string) => runtimeId === "grok" ? grokRegistration : undefined);
|
||||
const pluginRunner = createMockPluginRunner({ getRuntimeById });
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
runtimeHint: "pi",
|
||||
pluginRunner,
|
||||
cwd: "/tmp/project",
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
systemPrompt: "explicit-pi",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
expect(getRuntimeById).not.toHaveBeenCalledWith("grok");
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
defaultProvider: "grok-cli",
|
||||
defaultModelId: "grok-4.5",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not crash and falls back to pi for an empty/undefined runtimeConfig", async () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
GROK_CLI_PROVIDER_ID,
|
||||
isGrokApiKeyFusionVisible,
|
||||
isTestModeActive,
|
||||
resolveExecutionSettingsModel,
|
||||
resolveProjectDefaultModel,
|
||||
@@ -152,6 +154,28 @@ function hasCompleteRuntimeModel(
|
||||
return Boolean(model.provider && model.modelId);
|
||||
}
|
||||
|
||||
function stripGrokCliModelProviderPrefix(modelId: string | undefined): string | undefined {
|
||||
const normalized = modelId?.trim();
|
||||
if (!normalized) return normalized;
|
||||
const grokCliPrefix = `${GROK_CLI_PROVIDER_ID}/`;
|
||||
return normalized.startsWith(grokCliPrefix)
|
||||
? normalized.slice(grokCliPrefix.length)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function deriveGrokRuntimeHintForNoVisibleKey(
|
||||
runtimeOptions: AgentRuntimeOptions,
|
||||
pluginRunner: PluginRunner | undefined,
|
||||
): string | undefined {
|
||||
if (runtimeOptions.defaultProvider !== GROK_CLI_PROVIDER_ID) return undefined;
|
||||
if (isGrokApiKeyFusionVisible()) return undefined;
|
||||
try {
|
||||
return pluginRunner?.getRuntimeById("grok") ? "grok" : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pickSettingsThenRuntimeModel(
|
||||
settingsModel: ResolvedModelSelection,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
@@ -375,13 +399,32 @@ export async function createResolvedAgentSession(
|
||||
}
|
||||
: runtimeOptions;
|
||||
|
||||
/*
|
||||
FNXC:GrokCliRouting 2026-07-09-00:00:
|
||||
FN-7753: when Built-in Model mode selects `grok-cli/*` but Fusion cannot see a GROK_API_KEY,
|
||||
derive the existing Grok plugin runtime hint automatically so the `grok` binary owns auth end-to-end.
|
||||
Explicit runtime hints always win, visible keys keep the direct xAI endpoint default, and mock/test-mode
|
||||
provider routing stays on the mock runtime. Strip only the provider-qualified model prefix so the CLI
|
||||
receives the concrete selected model via GrokRuntimeAdapter without changing non-grok sessions.
|
||||
*/
|
||||
const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint
|
||||
? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner)
|
||||
: undefined;
|
||||
const effectiveRuntimeHint = autoGrokRuntimeHint ?? runtimeHint;
|
||||
const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint
|
||||
? {
|
||||
...effectiveRuntimeOptions,
|
||||
defaultModelId: stripGrokCliModelProviderPrefix(effectiveRuntimeOptions.defaultModelId),
|
||||
}
|
||||
: effectiveRuntimeOptions;
|
||||
|
||||
const resolved = useMockRuntime
|
||||
? {
|
||||
runtime: mockRuntimeSingleton,
|
||||
runtimeId: mockRuntimeSingleton.id,
|
||||
wasConfigured: true,
|
||||
}
|
||||
: await resolveRuntime(buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint));
|
||||
: await resolveRuntime(buildRuntimeResolutionContext(sessionPurpose, pluginRunner, effectiveRuntimeHint));
|
||||
|
||||
sessionLog.log(
|
||||
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
|
||||
@@ -399,8 +442,9 @@ export async function createResolvedAgentSession(
|
||||
modelId: runtimeOptions.defaultModelId ?? null,
|
||||
mockProviderActive: isMockProviderId(runtimeOptions.defaultProvider),
|
||||
testModeActive: settings ? isTestModeActive(settings) : false,
|
||||
...(runtimeHint ? { runtimeHint } : {}),
|
||||
...("fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
|
||||
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
|
||||
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
|
||||
...(!autoGrokRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -411,7 +455,7 @@ export async function createResolvedAgentSession(
|
||||
// latest sync point (just before LLM session instantiation) rather than
|
||||
// here, before the runtime's own awaited setup work runs. See
|
||||
// AgentRuntimeOptions.beforeSpawnSession for the contract.
|
||||
const result = await resolved.runtime.createSession(effectiveRuntimeOptions);
|
||||
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
|
||||
|
||||
// Attach the resolved runtime's promptWithFallback as a bound method on the
|
||||
// session object when it is not already present. This is the dispatch hook
|
||||
|
||||
@@ -76,18 +76,23 @@ grok --prompt "<text>" --format json
|
||||
`~/.grok/user-settings.json`), a CLI-routed selection needs **no
|
||||
Fusion-visible `GROK_API_KEY`** — unlike the direct xAI
|
||||
OpenAI-compatible streaming path (`https://api.x.ai/v1`), which still
|
||||
requires one.
|
||||
- This adapter is only reached when an agent's
|
||||
`runtimeConfig.runtimeHint === "grok"`. See "Routing Grok through the CLI
|
||||
runtime (FN-7725)" below for how to set that, and
|
||||
requires one. When Fusion auto-routes a no-key `grok-cli/*` model selection
|
||||
through this adapter (FN-7753), the selected model id is passed to the CLI
|
||||
with `--model <id>`.
|
||||
- This adapter is reached either when an agent explicitly sets
|
||||
`runtimeConfig.runtimeHint === "grok"` or when FN-7753's no-visible-key
|
||||
`grok-cli/*` fallback derives that hint automatically. See "Routing Grok
|
||||
through the CLI runtime (FN-7725 / FN-7753)" below and
|
||||
`docs/grok-cli-contract.md` for the full contract and decision record.
|
||||
|
||||
## Routing Grok through the CLI runtime (FN-7725)
|
||||
## Routing Grok through the CLI runtime (FN-7725 / FN-7753)
|
||||
|
||||
By default, selecting a `grok-cli/*` **model** for an agent/task still routes
|
||||
execution through the **direct xAI OpenAI-compatible endpoint**
|
||||
(`https://api.x.ai/v1`, FN-7711/FN-7714) — this default is unchanged by this
|
||||
plugin.
|
||||
By default, selecting a `grok-cli/*` **model** for an agent/task routes through
|
||||
the **direct xAI OpenAI-compatible endpoint** (`https://api.x.ai/v1`,
|
||||
FN-7711/FN-7714) whenever Fusion can see a `GROK_API_KEY` (environment or
|
||||
`~/.grok/user-settings.json` `apiKey`). If no Fusion-visible key resolves and
|
||||
the Grok Runtime plugin is registered, Fusion automatically routes that session
|
||||
through the `grok` CLI runtime instead, letting the CLI own auth end-to-end.
|
||||
|
||||
To route a specific agent's execution through the `grok` CLI's own
|
||||
non-interactive streaming mode (`grok --prompt --format json`) instead:
|
||||
@@ -104,16 +109,19 @@ non-interactive streaming mode (`grok --prompt --format json`) instead:
|
||||
child agent) resolves through `packages/engine/src/runtime-resolution.ts`
|
||||
to this plugin's `GrokRuntimeAdapter` instead of the default pi runtime.
|
||||
|
||||
**Known limitation:** Runtime-mode is model-agnostic — it does not carry a
|
||||
specific `grok-cli/*` model id through to the adapter, so
|
||||
`GrokRuntimeAdapter.createSession()` always falls back to `"grok/default"`.
|
||||
If you need a specific Grok model honored end-to-end, use the direct xAI
|
||||
endpoint path (**Built-in Model** → a `grok-cli/*` model) instead — that
|
||||
path does preserve model selection, just not via the CLI binary.
|
||||
**Automatic fallback precedence (FN-7753):** explicit runtime hint >
|
||||
Fusion-visible key/direct endpoint > automatic CLI fallback. The fallback is
|
||||
only derived when no explicit runtime hint is set, the provider is `grok-cli`,
|
||||
no Fusion-visible key resolves, and runtime id `"grok"` is registered. The
|
||||
selected model is normalized from `grok-cli/<id>` (or `grok/<id>`) to `<id>`
|
||||
and sent as `--model <id>`.
|
||||
|
||||
This routing is opt-in and per-agent; it does not change any other agent's
|
||||
or task's execution path, and it does not change what a `grok-cli/*` model
|
||||
selection does under **Built-in Model** mode.
|
||||
**Known limitation:** explicit Runtime-mode is still model-agnostic — it does
|
||||
not carry a specific `grok-cli/*` model id through to the adapter, so
|
||||
`GrokRuntimeAdapter.createSession()` falls back to `"grok/default"` and omits
|
||||
`--model`. Built-in Model selections preserve the model either through the
|
||||
direct endpoint (when a key is visible) or through the FN-7753 automatic CLI
|
||||
fallback (when no key is visible).
|
||||
|
||||
## Enable via Settings → Authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawnGrokStream } from "../cli-stream.js";
|
||||
|
||||
function mockPlatform(platform: NodeJS.Platform) {
|
||||
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
||||
}
|
||||
|
||||
function createMockChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
vi.mocked(spawn).mockReturnValue(child as never);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("spawnGrokStream", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPlatform("darwin");
|
||||
createMockChild();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes the selected model to grok --model when provided", () => {
|
||||
spawnGrokStream("grok", "hello", { cwd: "/tmp/project", model: "grok-4.5" });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("grok", [
|
||||
"--prompt",
|
||||
"hello",
|
||||
"--format",
|
||||
"json",
|
||||
"--model",
|
||||
"grok-4.5",
|
||||
"--directory",
|
||||
"/tmp/project",
|
||||
], {
|
||||
cwd: "/tmp/project",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits --model when no model is provided", () => {
|
||||
spawnGrokStream("grok", "hello", { cwd: "/tmp/project" });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("grok", [
|
||||
"--prompt",
|
||||
"hello",
|
||||
"--format",
|
||||
"json",
|
||||
"--directory",
|
||||
"/tmp/project",
|
||||
], expect.objectContaining({ cwd: "/tmp/project" }));
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,34 @@ describe("GrokRuntimeAdapter", () => {
|
||||
expect(result.session.systemPrompt).toBe("sys");
|
||||
});
|
||||
|
||||
it("passes the normalized selected model to the CLI spawn seam", async () => {
|
||||
const { proc } = makeFakeProc();
|
||||
const spawn = vi.fn().mockReturnValue(proc);
|
||||
const adapter = new GrokRuntimeAdapter({ spawn });
|
||||
const { session } = await adapter.createSession({ defaultModelId: "grok-cli/grok-4.5" });
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "hello grok");
|
||||
proc.emit("close", 0, null);
|
||||
await promise;
|
||||
|
||||
expect(session.model).toBe("grok-4.5");
|
||||
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: "grok-4.5" }));
|
||||
});
|
||||
|
||||
it("omits --model for the no-model grok/default fallback", async () => {
|
||||
const { proc } = makeFakeProc();
|
||||
const spawn = vi.fn().mockReturnValue(proc);
|
||||
const adapter = new GrokRuntimeAdapter({ spawn });
|
||||
const { session } = await adapter.createSession({});
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "hello grok");
|
||||
proc.emit("close", 0, null);
|
||||
await promise;
|
||||
|
||||
expect(session.model).toBe("grok/default");
|
||||
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: undefined }));
|
||||
});
|
||||
|
||||
it("streams onText for each text NDJSON event in order and resolves on close", async () => {
|
||||
const { proc, stdout } = makeFakeProc();
|
||||
const spawn = vi.fn().mockReturnValue(proc);
|
||||
|
||||
@@ -18,6 +18,7 @@ export type GrokStreamProcess = ChildProcessByStdio<null, Readable, Readable>;
|
||||
|
||||
export interface SpawnGrokStreamOptions {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,11 @@ export interface SpawnGrokStreamOptions {
|
||||
*/
|
||||
export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess {
|
||||
const args: string[] = ["--prompt", prompt, "--format", "json"];
|
||||
const model = options?.model?.trim();
|
||||
if (model) {
|
||||
// FNXC:GrokCliRouting 2026-07-09-00:00: FN-7753 preserves a selected `grok-cli/*` model when auto-routing through the CLI; upstream verifies `--model <model>` alongside `--prompt`/`--format json`.
|
||||
args.push("--model", model);
|
||||
}
|
||||
if (options?.cwd) {
|
||||
args.push("--directory", options.cwd);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ does, same `streamEnded`-guarded (via the existing `settled` flag)
|
||||
resolve-never-reject lifecycle as before. This adapter is only reached when
|
||||
an agent's `runtimeConfig.runtimeHint === "grok"` (wired end-to-end by
|
||||
FN-7725).
|
||||
|
||||
FNXC:GrokCliRouting 2026-07-09-00:00:
|
||||
FN-7753: auto-derived `grok` runtime routing from a `grok-cli/*` model selection must preserve the concrete model. Normalize provider-qualified ids (`grok-cli/<id>` or `grok/<id>`) at session creation/prompt time and pass only the concrete id to `grok --model`; the no-model Runtime-mode path keeps the historical `grok/default` session fallback and omits `--model`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -71,6 +74,23 @@ function parseToolArguments(raw: string | undefined): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGrokCliModel(model: string | undefined): string | undefined {
|
||||
const normalized = model?.trim();
|
||||
if (!normalized) return undefined;
|
||||
for (const prefix of ["grok-cli/", "grok/"]) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
const stripped = normalized.slice(prefix.length).trim();
|
||||
return stripped.length > 0 ? stripped : undefined;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function modelForCli(model: string | undefined): string | undefined {
|
||||
const normalized = normalizeGrokCliModel(model);
|
||||
return normalized && normalized !== "default" ? normalized : undefined;
|
||||
}
|
||||
|
||||
export interface GrokRuntimeAdapterOptions {
|
||||
/** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */
|
||||
binary?: string;
|
||||
@@ -99,7 +119,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
||||
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
|
||||
} = {},
|
||||
): Promise<AgentSessionResult> {
|
||||
const model = options.defaultModelId ?? "grok/default";
|
||||
const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default";
|
||||
const session: GrokSession = {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
@@ -124,7 +144,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
||||
return new Promise<void>((resolve) => {
|
||||
let proc: GrokStreamProcess;
|
||||
try {
|
||||
proc = this.spawnFn(this.binary, prompt, { cwd, signal });
|
||||
proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal });
|
||||
} catch {
|
||||
// Spawn threw synchronously (e.g. binary not found without shell
|
||||
// resolution) — resolve, never reject, matching the CLI-adapter
|
||||
|
||||
Reference in New Issue
Block a user