From db9b9d22c4e78372c3929ad6f37b42093efc9733 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 10:57:04 -0700 Subject: [PATCH] FN-7790: align grok CLI runtime adapter with real xAI grok CLI contract Align the Grok CLI runtime plugin adapter with the real xAI grok CLI stream contract so tool responses are no longer silently dropped, and update tests/docs to match. - Rework stream-parser.ts to parse the actual grok CLI event/message shape instead of the previously assumed schema - Trim runtime-adapter.ts and types.ts down to the fields the real CLI contract emits, removing speculative/unsupported fields - Update cli-stream.ts to match the corrected event handling - Rewrite runtime-adapter, stream-parser, and cli-stream test suites to exercise the real CLI contract end-to-end - Update docs/grok-cli-contract.md and plugin README to document the verified contract - Add changeset for the grok-runtime plugin fix Files changed: .changeset/fn-7790-grok-cli-real-contract.md | 7 + docs/grok-cli-contract.md | 432 +++++++-------------- plugins/fusion-plugin-grok-runtime/README.md | 149 ++----- .../src/__tests__/cli-stream.test.ts | 22 +- .../src/__tests__/runtime-adapter.test.ts | 250 ++++-------- .../src/__tests__/stream-parser.test.ts | 108 ++---- .../fusion-plugin-grok-runtime/src/cli-stream.ts | 22 +- .../src/runtime-adapter.ts | 109 ++---- .../src/stream-parser.ts | 27 +- plugins/fusion-plugin-grok-runtime/src/types.ts | 104 +---- 10 files changed, 338 insertions(+), 892 deletions(-) Fusion-Task-Id: FN-7790 Fusion-Task-Lineage: 377e86c4-c005-46a5-9eb4-69e858c38b79 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7790-grok-cli-real-contract.md | 7 + docs/grok-cli-contract.md | 390 +++++------------- plugins/fusion-plugin-grok-runtime/README.md | 137 ++---- .../src/__tests__/cli-stream.test.ts | 22 +- .../src/__tests__/runtime-adapter.test.ts | 252 ++++------- .../src/__tests__/stream-parser.test.ts | 108 ++--- .../src/cli-stream.ts | 22 +- .../src/runtime-adapter.ts | 107 +---- .../src/stream-parser.ts | 27 +- .../fusion-plugin-grok-runtime/src/types.ts | 104 +---- 10 files changed, 311 insertions(+), 865 deletions(-) create mode 100644 .changeset/fn-7790-grok-cli-real-contract.md diff --git a/.changeset/fn-7790-grok-cli-real-contract.md b/.changeset/fn-7790-grok-cli-real-contract.md new file mode 100644 index 0000000000..23ab106629 --- /dev/null +++ b/.changeset/fn-7790-grok-cli-real-contract.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Grok CLI runtime sends to stream responses from xAI's real grok binary. +category: fix +dev: Uses `grok -p --output-format streaming-json` and parses `thought`/`text`/`end` events. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index 13c595a1c7..745bbd2633 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -1,321 +1,157 @@ -# Grok CLI Contract (FN-7722) +# Grok CLI Contract (FN-7790) -Date: 2026-07-09 +Date: 2026-07-10 -## Research method +## Ground truth -- `fn_web_fetch` against the canonical upstream repository - (https://github.com/superagent-ai/grok-cli), specifically: - - `README.md` (headless-mode overview, feature summary). - - `src/index.ts` (commander CLI argument parsing — the exact flag - spellings and headless dispatch). - - `src/headless/output.ts` (the actual NDJSON event emitter — the - authoritative schema source, not just docs prose). - - `src/headless/output.test.ts` (fixture-level confirmation of the emitted - JSONL shapes, used as ground truth for this plugin's own fixture tests). -- No live `grok` binary was invoked; no field name or flag spelling in this - document is guessed — every claim below traces to one of the four files - above. Raw captured research (queries + verbatim schema) is preserved as - this task's `research` task document (`fn_task_document_read` key - `research` on FN-7722). +Fusion shells out to an **operator-installed** `grok` binary. The binary is not downloaded or bundled by Fusion, so the authoritative contract is the installed xAI CLI's own help/version output plus live execution on an authenticated machine. + +External integration evidence: + +- Canonical upstream: xAI official Grok CLI / Grok Build TUI, surfaced by the installed binary as `grok 0.2.93 (f00f96316d4b)`. +- Docs/homepage: https://grok.com/, https://docs.x.ai/, and `grok --help` / `grok agent --help` for exact flags. +- Release/download: operator-installed; Fusion resolves `grok` from PATH or `grokCliBinaryPath` and does not bundle a release artifact. +- Binary name: `grok`. +- Checksum: `upstream-pending-verification` because Fusion does not pin or download the operator's binary. + +The previously documented https://github.com/superagent-ai/grok-cli contract is a different product that happens to use the same binary name. Its `grok --prompt --format json` invocation is not accepted by xAI's CLI. + +## Failure that caused FN-7790 + +The old adapter invocation fails against the real xAI binary: + +```bash +grok --prompt "say hello" --format json +``` + +Observed result: + +```text +exit 2 +stdout: +stderr: +error: unexpected argument '--prompt' found + + tip: a similar argument exists: '--prompt-file' + +Usage: grok --prompt-file [PROMPT] +``` + +Because no NDJSON `text` event is produced, Fusion surfaced a blank/no-message assistant response. ## Confirmed non-interactive invocation +Use xAI Grok Build TUI's single-turn prompt mode with streaming JSON: + ```bash -grok --prompt "" --format json -# short flags: -grok -p "" --format json +grok -p "" --output-format streaming-json +# equivalent long prompt flag: +grok --single "" --output-format streaming-json ``` -- `-p, --prompt ` — run a single prompt headlessly, then exit. -- `--format ` — headless output format, `text` (default) or `json`; - invalid values are rejected by commander's `InvalidArgumentError` - (`parseHeadlessOutputFormat`/`isHeadlessOutputFormat` in `src/index.ts`). -- Useful companion flags confirmed in the same `program.option(...)` chain: - `-d, --directory ` (cwd), `-m, --model `, `-s, --session ` - (resume a saved session, or `latest`), `-k, --api-key ` (inline key). -- `--format json` output is **newline-delimited JSON (NDJSON/JSONL)** — one - JSON object per line — not a single JSON document. This is directly - confirmed by `createHeadlessJsonlEmitter()`'s `jsonLine()` helper in - `src/headless/output.ts`, which appends `\n` after each `JSON.stringify`. +Supported companion flags used by Fusion: -## Verified NDJSON event schema (verbatim) +- `-p, --single ` — run a single prompt, print the response, and exit. This does not require interactive stdin. +- `--output-format ` — streaming adapter uses `streaming-json`. +- `-m, --model ` — optional concrete model id. Fusion omits this for the model-less `grok/default` Runtime-mode path. +- `--cwd ` — optional working directory. This replaces the wrong-product `--directory` flag. -Source: `HeadlessJsonEvent` union type in `src/headless/output.ts`. +Other observed flags include `--prompt-file `, `--prompt-json `, `-s/--session-id `, `--sandbox `, `--system-prompt-override `, and `--max-turns `, but Fusion's adapter does not currently use them. + +## Streaming JSON event schema + +`--output-format streaming-json` emits one JSON object per line: ```ts -type HeadlessJsonEvent = - | { type: "step_start"; sessionID?: string; stepNumber: number; timestamp: number } - | { type: "text"; sessionID?: string; stepNumber: number; text: string; timestamp: number } - | { - type: "tool_use"; - sessionID?: string; - stepNumber: number; - timestamp: number; - toolCall: ToolCall; - toolResult: ToolResult; - timing?: { startedAt?: number; finishedAt?: number; durationMs?: number }; - } - | { - type: "step_finish"; - sessionID?: string; - stepNumber: number; - timestamp: number; - finishReason: string; - usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; costUsdTicks?: number }; - } - | { type: "error"; sessionID?: string; message: string; timestamp: number }; +type GrokStreamingJsonEvent = + | { type: "thought"; data: string } + | { type: "text"; data: string } + | { type: "end"; stopReason?: string; sessionId?: string; requestId?: string }; ``` -Notes: +Mapping in Fusion: -- `sessionID` appears on every event type when a session id is available - (`agent.getSessionId()`); it is simply absent from the JSON object - otherwise (not `null`). -- `text` events are per-step, buffered assistant content — one `text` event - per step carrying the accumulated text for that step, flushed either right - before a tool-triggering `step_finish` or inline with a tool-less - `step_finish`. -- **No `thinking`/`reasoning` NDJSON event exists.** The underlying - `StreamChunk` union used internally does carry a `"reasoning"` chunk type, - but `createHeadlessJsonlEmitter().consumeChunk()` explicitly no-ops on it - (`case "reasoning": break;` in `src/headless/output.ts`) — reasoning - content is never surfaced through `--format json`. This is a **confirmed - absence**, not `upstream-pending-verification`: the Grok streaming adapter - therefore drives `onText` only; there is no `onThinking` signal to bridge - for this CLI path today. -- There is **no explicit terminal `done`/`result` event type**. A prompt run - can contain multiple `step_start`/`step_finish` pairs (multi-round tool - use); the authoritative "the run is over" signal is the headless process's - stdout stream ending (readline `close`) / subprocess exit, mirroring how - the Droid CLI adapter treats subprocess `close` as terminal. `error` events - (`{ type: "error", message, timestamp }`) can also appear inline without - necessarily ending the process. -- A **fatal, pre-JSON failure** (e.g. missing API key) is not a JSON line at - all: `src/index.ts`'s `requireApiKey()` writes a plain `console.error(...)` - line to stderr and calls `process.exit(1)` before any NDJSON is emitted. - Consumers must therefore also treat a non-zero exit with no JSON output as - a distinct failure mode from a well-formed `error` event. -- A **code-0 run with zero parsed NDJSON events is anomalous**, not a valid - empty assistant response. A supported headless prompt emits at least - `step_start`; when Fusion sees stdout close + process close(0) with no - parsed NDJSON, it surfaces a diagnostic instead of persisting a mystery - empty message. This shape can occur when the `grok` binary on PATH is the - wrong/unsupported binary or falls back to an interactive mode that exits - immediately after stdin EOF. +- `thought.data` → `onThinking(thought.data)`. +- `text.data` → `onText(text.data)` and accumulated assistant content. +- `end.sessionId` → `session.sessionId` when present. `end` pre-signals terminal output, but subprocess `close` remains the authoritative promise resolution point because it carries exit status/stderr diagnostics. - +Real captured tail: -## Auth / readiness +```jsonl +{"type":"thought","data":" one"} +{"type":"thought","data":"-"} +{"type":"thought","data":"word"} +{"type":"thought","data":" greeting"} +{"type":"thought","data":"."} +{"type":"text","data":"Hello"} +{"type":"text","data":"!"} +{"type":"end","stopReason":"EndTurn","sessionId":"019f4d1e-2582-70e0-a174-c8774782ab01","requestId":"2233f1dc-e9ad-4ae4-8221-caa6afade07f"} +``` -- **The `grok` CLI owns authentication end-to-end for CLI-routed execution.** - `runHeadless()` in `src/index.ts` is only reached via - `requireApiKey(config.apiKey)`, which resolves the key from (in order via - `resolveConfig`/`getApiKey()`): `-k/--api-key` flag, `GROK_API_KEY` env var, - project `.env`, or `~/.grok/user-settings.json`'s `apiKey` field. If none - resolve, the CLI itself exits 1 with an actionable error — Fusion does not - need to pass, see, or validate a key for this path to work, as long as the - operator's `grok` install already has one configured by any of those - methods. -- **Auth implication for this task:** because CLI-routed model selections let - the `grok` binary own both auth and inference, the direct-endpoint - `GROK_API_KEY` Fusion-visibility requirement established by FN-7711 - (built-in `xai`/`openai-completions` provider) and FN-7714 (hydrating - `GROK_API_KEY` from `~/.grok/user-settings.json` when the env var is unset) - becomes **unnecessary for CLI-routed selections specifically**. It remains - necessary and unchanged for the direct xAI OpenAI-compatible path, which - stays the default (see "What stays unchanged" below). -- This mirrors FN-7716's separate finding that Grok CLI *readiness* (probe/ - auth-status surfacing) does not require Fusion to see a key either — that - surface (`probe.ts`, `register-auth-routes.ts`, - `GrokCliProviderCard.tsx`) is out of scope for this task and is not - modified here. +A successful run exits 0 with empty stderr. -## Wiring (resolved — FN-7725, extended by FN-7753/FN-7758/FN-7761) +## Non-streaming formats - +`--output-format plain` prints renderable response text. -**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. +`--output-format json` emits one final JSON object rather than an NDJSON stream. Observed shape: -**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). +```json +{ + "text": "hi", + "stopReason": "EndTurn", + "sessionId": "019f4d18-875b-7662-9bc5-9b71fa0aa6b0", + "requestId": "0e8ef53f-5a5f-4564-a8fd-0200ef96440e", + "thought": "The user wants me to say hi in one word..." +} +``` -**Automatic no-key fallback (FN-7753/FN-7758/FN-7761):** when `createResolvedAgentSession()` sees -all of the following, it derives the same effective `runtimeHint: "grok"` before -calling `resolveRuntime()`: +Fusion uses `streaming-json` for live `onText`/`onThinking` callbacks. -1. no explicit runtime hint was supplied (explicit hints, including `"pi"`, - always win); -2. the resolved primary/default provider is `grok-cli`, or the configured - fallback 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"`. +## Model discovery - +`grok models` is plain text, not JSON. Observed shape: -FN-7758 also requires dashboard Chat/QuickChat and room responders to forward -the configured default provider/model into this same session seam when a send has -no explicit model and no bound agent runtime model. That keeps the no-key routing -invariant identical across executor, reviewer/validator/merger-adjacent, single -chat, QuickChat, and room responder surfaces instead of letting model-less chat -bypass the auto-derive by omitting `defaultProvider`. +```text +You are logged in with grok.com. -If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains -the default. If the Grok runtime is not registered or cannot be loaded in the -no-visible-key `grok-cli` case, Fusion no longer leaves the session on the -existing PI/direct path because that path produces the misleading missing-key -error. Instead it raises an actionable error that names both supported recovery -paths: install/enable the Grok CLI runtime plugin so the logged-in `grok` CLI -owns auth, or set `GROK_API_KEY` so the direct xAI endpoint can authenticate. +Default model: grok-4.5 -**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 -`packages/engine/src/runtime-resolution.ts`'s `resolveRuntime()` — which is -totally runtime-agnostic: when the hint matches a registered plugin -`runtimeId`, `resolvePluginRuntime()` calls that plugin's `runtime.factory` -(the Grok plugin's factory returns `new GrokRuntimeAdapter()`, -`plugins/fusion-plugin-grok-runtime/src/index.ts`) and the resolved adapter -becomes the session's runtime. This same generic chain already carries -`"hermes"` and `"droid"` runtime hints end-to-end (see -`hermes-runtime-integration.test.ts`, `droid-runtime-e2e.test.ts`) — Grok's -plugin registration alone was sufficient for the chain to reach it; **no -engine or dashboard code changed for this task**, because the generic -Runtime-mode picker → `extractRuntimeHint` → `resolveRuntime` → -`resolvePluginRuntime` → plugin `factory` chain was already correct and -exercised for other plugin runtimes. FN-7725 formalizes this as the *decided* -Grok wiring, adds `packages/engine/src/__tests__/grok-runtime-routing.test.ts` -proving the chain specifically resolves `GrokRuntimeAdapter` (id `"grok"`) -and drives its `onText` streaming seam via a faked spawn (see -`runtime-adapter.ts`'s injectable `spawn` option; no live `grok` binary), and -records the decision here plus in `plugins/fusion-plugin-grok-runtime/README.md`. +Available models: + * grok-4.5 (default) + - grok-composer-2.5-fast +``` -**Why option (a), not (b):** option (b) (an opt-in "prefer CLI runtime" -setting deriving `runtimeHint: "grok"` from a `grok-cli/*` model selection) -would add a new `Settings` field, defaulting/resolution logic, and a -SettingsModal UI toggle (desktop + mobile) — net-new surface area for a path -that, on inspection, was **already fully wired generically** by the existing -Runtime-mode picker. Per the Decision guidance's preference for "the smaller, -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. +Fusion parses the bullet list conservatively and exposes ids under provider `grok-cli` when the `useGrokCli` toggle is enabled. -**Model plumbing (FN-7753/FN-7758/FN-7761):** for the automatic no-key fallback, the selected -`grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId` -(or promoted from `fallbackModelId` when the fallback provider is the grok-cli -selection), normalized by stripping a leading `grok-cli/` (or `grok/`) prefix, -and passed to the CLI as `grok --model ` 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`. +## Auth and readiness -**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.). +The CLI owns authentication for CLI-routed execution. Fusion's readiness probe uses `grok --version`; a passing probe proves only that a compatible-looking binary exists, not that the prompt path is authenticated or serviceable. The prompt path is proven by a real `grok -p ... --output-format streaming-json` run. -## Decision +Fusion-visible `GROK_API_KEY` remains relevant for the direct xAI OpenAI-compatible endpoint. For CLI-routed sessions, Fusion does not need to see a key as long as the operator-installed CLI is authenticated by its own supported mechanism. -**Route Grok execution through the CLI: YES, as a scoped, additive -`GrokRuntimeAdapter` implementation.** +## Runtime routing -Rationale: +The Grok runtime adapter is reached when: -- The non-interactive contract is fully pinned to primary source code - (`src/index.ts` CLI parsing + `src/headless/output.ts` emitter + - `src/headless/output.test.ts` fixtures), not just README prose — this - clears the External Integration Evidence bar and the "testable from - fixture lines without a live binary" bar from the task mission — the - parser can be fixture-tested exactly like the Droid plugin's - `stream-parser.ts`, with no live-binary dependency in tests. -- The event schema is simple (`step_start` / `text` / `tool_use` / - `step_finish` / `error`) and text-only for this scoped adapter (no - `thinking` event exists to bridge), so the implementation stays narrow: a - resilient NDJSON line parser plus an `onText` bridge, deliberately leaving - 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 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. +1. an agent explicitly sets `runtimeConfig.runtimeHint === "grok"`; or +2. the FN-7753/FN-7758 no-visible-key fallback derives the same runtime hint for a `grok-cli/*` default/fallback provider selection and the bundled Grok Runtime plugin is registered. -## What stays unchanged +The selected `grok-cli/` or `grok/` model is normalized to `` and passed to the CLI as `-m `. The explicit no-model Runtime-mode path keeps `grok/default` and omits `-m`. -- 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 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. -- End-to-end routing was out of scope for FN-7722 and is resolved by - FN-7725 (see "Wiring" above): decision option (a), formalizing the - existing agent Runtime-mode picker path. No settings toggle was added. +## Diagnostics and empty-output invariant -## Follow-ups filed from this task +The adapter preserves the resolve-never-reject runtime contract while surfacing concrete diagnostics: -See the task's `fn_task_create` calls (linked from FN-7722) for: +- spawn failure → `session.state.errorMessage` and diagnostic `onText`. +- non-zero subprocess close with no text → stderr/exit diagnostic. +- code-0 close with zero parsed NDJSON → wrong-binary/interactive-EOF diagnostic. +- parsed `end` with no accumulated assistant text → legitimate silent response, not a diagnostic. +- text emitted before a noisy/non-zero close → keep the assistant text and avoid replacing it with an error. -1. ~~End-to-end routing wiring~~ — **closed by FN-7725** (see "Wiring" - above): the agent Runtime-mode picker path was formalized, documented, - and covered by `packages/engine/src/__tests__/grok-runtime-routing.test.ts`. -2. ~~Full tool-call bridging for `tool_use` NDJSON events~~ — **closed by - FN-7724**: `GrokRuntimeAdapter` now bridges `tool_use` into - `onToolStart`/`onToolEnd` (no Grok→pi tool-name mapping was added — the - verified schema does not pin grok-cli's tool-name vocabulary, so - names/args pass through unchanged). Break-early on `step_finish` was - deliberately NOT adopted: this doc's own "Verified NDJSON event schema" - notes above establish `step_finish` is a per-step boundary (a run can - contain multiple `step_start`/`step_finish` pairs), not the run - terminal — the adapter's terminal signal remains subprocess - `close`/`error`, unchanged from FN-7722. See - `plugins/fusion-plugin-grok-runtime/README.md`'s "Tool execution - bridging (FN-7724)" section. -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 `. - Explicit Runtime-mode remains model-agnostic by design. +This invariant prevents the original blank/no-message symptom while still allowing genuinely empty model turns. diff --git a/plugins/fusion-plugin-grok-runtime/README.md b/plugins/fusion-plugin-grok-runtime/README.md index 06165787ea..4f0542d724 100644 --- a/plugins/fusion-plugin-grok-runtime/README.md +++ b/plugins/fusion-plugin-grok-runtime/README.md @@ -8,84 +8,40 @@ This plugin ships bundled with Fusion and is auto-installed like the other built-in runtime plugins. It shells out to an **operator-installed** `grok` binary on PATH — Fusion never downloads or bundles the CLI itself. -- Canonical upstream repo: https://github.com/superagent-ai/grok-cli -- Docs / homepage: https://github.com/superagent-ai/grok-cli#readme -- Install script: https://raw.githubusercontent.com/superagent-ai/grok-cli/main/install.sh -- npm alternative: `bun add -g grok-dev` (see https://github.com/superagent-ai/grok-cli/releases) -- Binary name: `grok` -- This is a community-built project, not affiliated with xAI. No fixed - release artifact is bundled by Fusion, so no checksum is pinned - (`upstream-pending-verification`). +- Canonical upstream: xAI official Grok CLI / Grok Build TUI (`grok --version` observed as `grok 0.2.93 (f00f96316d4b)`). +- Docs / homepage: https://grok.com/, https://docs.x.ai/, and `grok --help` / `grok agent --help` for exact flags. +- Release / download: operator-installed; Fusion resolves `grok` from PATH or `grokCliBinaryPath`. +- Binary name: `grok`. +- Checksum: `upstream-pending-verification` because Fusion does not download or pin the operator's binary. + +The previously assumed `superagent-ai/grok-cli` contract is a different product that shares the `grok` binary name. This plugin targets xAI's official CLI contract. ## Contract summary - Provider ID: `grok-cli` - Binary probe: `grok --version` -- **Auth model — the `grok` CLI owns its own authentication; Fusion does - not require a Fusion-visible API key to enable/use it (FN-7716).** Grok - has no `status`/`whoami` subcommand, so Fusion probes binary availability - only and treats a working binary as "ready" (`authenticated: true`). The - CLI itself resolves credentials from more sources than Fusion can see - (`GROK_API_KEY` env var, a project `.env`, `grok -k `, - `GROK_BASE_URL`, sandbox secrets, etc.). Fusion additionally probes two of - those locations — the `GROK_API_KEY` env var and - `~/.grok/user-settings.json` → `{ "apiKey": "..." }` — purely as a - **non-blocking informational hint** (`apiKeyDetected`); it never gates - Enable or the authenticated state, and a missing/unreadable/malformed - settings file degrades gracefully (never throws). The direct xAI - OpenAI-compatible streaming path (base URL `https://api.x.ai/v1`) still - uses `$GROK_API_KEY` when present, independent of the CLI provider. -- Model discovery: `grok models` (plain-text output, with pricing hints per - the upstream README). The exact line shape is - `upstream-pending-verification`, so discovery parses conservatively: the - leading token before a ` - ` label separator, or before the first - multi-space pricing column, is treated as the model id; ids are - deduplicated. Output that happens to be JSON is tolerated defensively even - though the CLI is not known to emit it. +- **Auth model — the `grok` CLI owns its own authentication; Fusion does not require a Fusion-visible API key to enable/use it (FN-7716).** Fusion additionally probes the `GROK_API_KEY` env var and `~/.grok/user-settings.json` → `{ "apiKey": "..." }` purely as a **non-blocking informational hint** (`apiKeyDetected`); it never gates Enable or the authenticated state. The direct xAI OpenAI-compatible streaming path (base URL `https://api.x.ai/v1`) still uses `$GROK_API_KEY` when present, independent of the CLI provider. +- Model discovery: `grok models` (plain text). The observed xAI shape is `Default model: `, then `Available models:`, then `* (default)` / `- ` bullet rows. -## CLI streaming execution path (FN-7722) +## CLI streaming execution path (FN-7790) -In addition to model discovery/probe, this plugin's `GrokRuntimeAdapter` can -stream a real Grok response through the CLI itself: +The plugin's `GrokRuntimeAdapter` streams a real Grok response through xAI's CLI: ```bash -grok --prompt "" --format json +grok -p "" --output-format streaming-json +# with optional model/cwd: +grok -p "" --output-format streaming-json -m "grok-4.5" --cwd "/path/to/project" ``` -- `--format json` emits newline-delimited JSON (NDJSON) — one JSON object - per line — with event types `step_start`, `text`, `tool_use`, - `step_finish`, and `error` (verified against upstream source, not just - docs prose; see `docs/grok-cli-contract.md`). -- The adapter parses that stream (`src/stream-parser.ts`) and drives - `onText` as `text` events arrive. There is no `thinking`/`reasoning` event - in the verified schema, so `onThinking` is never invoked for this path. -- **Tool execution bridging (FN-7724):** each verified `tool_use` event - (`toolCall`/`toolResult`/`timing`) additionally drives `onToolStart(toolName, - args)` / `onToolEnd(toolName, isError, result)`, mirroring the Droid - plugin's `DroidCallbacks` shape. `toolName`/`args` are - `toolCall.function.name` / parsed `toolCall.function.arguments`; - `isError` derives from `toolResult.success === false`. No Grok→pi - tool-name/arg translation is applied — the verified contract does not pin - grok-cli's specific tool-name vocabulary (unlike Droid's Claude-shaped - names), so names/args pass through unchanged. `step_finish` is a per-step - boundary (a run can contain multiple), not the run terminal, so it does - not finalize the adapter's promise; only subprocess `close`/`error` does, - unchanged from FN-7722. -- **Auth implication:** because the `grok` binary resolves its own - credentials for this path (env var, project `.env`, `grok -k`, or - `~/.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. 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 `. -- 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. +- `-p, --single ` runs a single prompt and exits; it does not require interactive stdin. +- `--output-format streaming-json` emits NDJSON with event types `thought`, `text`, and `end`. +- `thought.data` drives `onThinking`; `text.data` drives `onText` and persisted assistant content; `end.sessionId` is stored when present. The subprocess `close` event remains the authoritative resolution point so stderr/exit diagnostics are preserved. +- A wrong-binary/wrong-flag run that emits no parsed NDJSON surfaces a concrete diagnostic instead of a blank assistant response. A real `end` event with empty accumulated text remains a legitimate silent response. +- **Auth implication:** because the `grok` binary resolves its own credentials for this path, a CLI-routed selection needs **no Fusion-visible `GROK_API_KEY`** — unlike the direct xAI OpenAI-compatible streaming path. -## Routing Grok through the CLI runtime (FN-7725 / FN-7753) +See `docs/grok-cli-contract.md` for the full contract, live captures, and the reason Fusion no longer uses the old `grok --prompt --format json` / `step_*` schema. + +## Routing Grok through the CLI runtime (FN-7725 / FN-7753 / FN-7790) By default, selecting a `grok-cli/*` **model** for an agent/task routes through the **direct xAI OpenAI-compatible endpoint** (`https://api.x.ai/v1`, @@ -94,51 +50,24 @@ FN-7711/FN-7714) whenever Fusion can see a `GROK_API_KEY` (environment or 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: +To route a specific agent's execution through the `grok` CLI runtime explicitly: -1. Open the agent in the dashboard (**New Agent** or an existing agent's - detail view). -2. Under **Runtime Source**, choose **Runtime** instead of **Built-in - Model**. -3. Select **Grok Runtime** from the runtime dropdown (sourced from - `GET /api/plugins/runtimes`, which lists every installed plugin runtime - including this one). -4. Save. The agent's `runtimeConfig.runtimeHint` is now `"grok"`; every - session that agent drives (as an assigned executor, column agent, or - child agent) resolves through `packages/engine/src/runtime-resolution.ts` - to this plugin's `GrokRuntimeAdapter` instead of the default pi runtime. +1. Open the agent in the dashboard (**New Agent** or an existing agent's detail view). +2. Under **Runtime Source**, choose **Runtime** instead of **Built-in Model**. +3. Select **Grok Runtime** from the runtime dropdown (sourced from `GET /api/plugins/runtimes`). +4. Save. The agent's `runtimeConfig.runtimeHint` is now `"grok"`; every session that agent drives resolves through this plugin's `GrokRuntimeAdapter` instead of the default pi runtime. -**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/` (or `grok/`) to `` -and sent as `--model `. +**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/` (or `grok/`) to `` and sent as `-m `. -**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). +**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 `-m`. 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 -1. Install the `grok` CLI and authenticate it by any method it supports - (env var, project `.env`, `grok -k`, etc.) — Fusion does not need to see - the key. +1. Install the `grok` CLI and authenticate it by any method it supports — Fusion does not need to see the key. 2. Open Settings → Authentication in the Fusion dashboard. -3. The "Grok — via Grok CLI" card shows probe status. Click **Enable** once - the binary is available; a non-blocking hint appears only if Fusion did - not detect a key, noting the direct xAI streaming path uses - `GROK_API_KEY` when present. -4. Discovered Grok models (via `grok models`) then merge into the model - picker under the `grok-cli` provider id. +3. The "Grok — via Grok CLI" card shows probe status. Click **Enable** once the binary is available; a non-blocking hint appears only if Fusion did not detect a key, noting the direct xAI streaming path uses `GROK_API_KEY` when present. +4. Discovered Grok models (via `grok models`) then merge into the model picker under the `grok-cli` provider id. ## Notes -Do not invent a `grok status`/`whoami` JSON auth contract — readiness is -derived from binary availability, mirroring the Cursor CLI provider. See -`AGENTS.md`'s "External-integration evidence" policy for why the -release/checksum fields above stay at `upstream-pending-verification`. +Do not invent a `grok status`/`whoami` JSON auth contract — readiness is derived from binary availability, mirroring the Cursor CLI provider. See `AGENTS.md`'s "External-integration evidence" policy for why the release/checksum fields above stay at `upstream-pending-verification`. diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts index ef29e60bb6..6789d215a9 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/cli-stream.test.ts @@ -35,17 +35,17 @@ describe("spawnGrokStream", () => { vi.restoreAllMocks(); }); - it("passes the selected model to grok --model when provided", () => { + it("passes the selected model and cwd using the real xAI Grok CLI flags", () => { spawnGrokStream("grok", "hello", { cwd: "/tmp/project", model: "grok-4.5" }); expect(spawn).toHaveBeenCalledWith("grok", [ - "--prompt", + "-p", "hello", - "--format", - "json", - "--model", + "--output-format", + "streaming-json", + "-m", "grok-4.5", - "--directory", + "--cwd", "/tmp/project", ], { cwd: "/tmp/project", @@ -55,15 +55,15 @@ describe("spawnGrokStream", () => { }); }); - it("omits --model when no model is provided", () => { + it("omits -m when no model is provided", () => { spawnGrokStream("grok", "hello", { cwd: "/tmp/project" }); expect(spawn).toHaveBeenCalledWith("grok", [ - "--prompt", + "-p", "hello", - "--format", - "json", - "--directory", + "--output-format", + "streaming-json", + "--cwd", "/tmp/project", ], expect.objectContaining({ cwd: "/tmp/project" })); }); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts index 2d8603ea1c..4b5411d0e8 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -1,18 +1,12 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GrokRuntimeAdapter } from "../runtime-adapter.js"; import type { GrokStreamProcess } from "../cli-stream.js"; +import { GrokRuntimeAdapter } from "../runtime-adapter.js"; /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: replaces FN-7715's "intentional no-op" assertion. `promptWithFallback` -is now a real NDJSON streaming implementation; these tests inject a FAKE -stdout stream (no live binary, no real subprocess spawn) through the -constructor's `spawn` seam and feed verified-shape NDJSON fixture lines -(docs/grok-cli-contract.md), asserting onText fires in order and the promise -resolves on close/error. Uses fake timers for the lifecycle timeout paths -per AGENTS.md "Do Not Add Slow Tests". +FNXC:GrokCli 2026-07-10-11:05: +FN-7790: adapter tests are pinned to the operator-verified xAI Grok Build TUI stream (`thought`/`text`/`end` with `data`). They intentionally avoid a live binary in CI but exercise the same spawn seam and lifecycle diagnostics that previously hid the wrong `--prompt`/`--format json` contract behind fake superagent-ai fixtures. */ function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: PassThrough; kill: ReturnType } { @@ -24,6 +18,10 @@ function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: return { proc, stdout, stderr, kill }; } +function closeProc(proc: GrokStreamProcess, code = 0, signal: NodeJS.Signals | null = null): void { + proc.emit("close", code, signal); +} + describe("GrokRuntimeAdapter", () => { it("creates a session with default model fallback", async () => { const adapter = new GrokRuntimeAdapter(); @@ -39,68 +37,87 @@ describe("GrokRuntimeAdapter", () => { const { session } = await adapter.createSession({ defaultModelId: "grok-cli/grok-4.5" }); const promise = adapter.promptWithFallback(session, "hello grok"); - proc.emit("close", 0, null); + closeProc(proc); 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 () => { + it("omits -m 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); + closeProc(proc); 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 () => { + it("bridges real xAI thought/text/end events and persists assistant content", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); + const onThinking = vi.fn(); + const { session } = await adapter.createSession({ onText, onThinking }); const promise = adapter.promptWithFallback(session, "hello grok"); - - stdout.write(`${JSON.stringify({ type: "step_start", stepNumber: 1, timestamp: 1 })}\n`); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 1, text: "hel", timestamp: 2 })}\n`); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 1, text: "lo!", timestamp: 3 })}\n`); - stdout.write( - `${JSON.stringify({ type: "step_finish", stepNumber: 1, timestamp: 4, finishReason: "stop", usage: {} })}\n`, - ); - proc.emit("close", 0, null); - + stdout.write(`${JSON.stringify({ type: "thought", data: "Thinking" })}\n`); + stdout.write(`${JSON.stringify({ type: "text", data: "Hel" })}\n`); + stdout.write(`${JSON.stringify({ type: "text", data: "lo" })}\n`); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "session-1", requestId: "request-1" })}\n`); + closeProc(proc); await promise; - expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({})); - expect(onText.mock.calls.map((c) => c[0])).toEqual(["hel", "lo!"]); - expect(session.state.messages).toContainEqual({ role: "assistant", content: "hello!" }); + expect(onThinking.mock.calls.map((c) => c[0])).toEqual(["Thinking"]); + expect(onText.mock.calls.map((c) => c[0])).toEqual(["Hel", "lo"]); + expect(session.sessionId).toBe("session-1"); + expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); }); - it("skips malformed/unrecognized lines without invoking onText and without throwing", async () => { + it("bridges a single text event without thought events", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); const onText = vi.fn(); const { session } = await adapter.createSession({ onText }); - const promise = adapter.promptWithFallback(session, "hi"); + const promise = adapter.promptWithFallback(session, "one word"); + stdout.write(`${JSON.stringify({ type: "text", data: "Hello" })}\n`); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); + closeProc(proc); + await promise; + expect(onText).toHaveBeenCalledWith("Hello"); + expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); + }); + + it("skips malformed, non-JSON, and legacy wrong-product lines without callbacks", async () => { + const { proc, stdout } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + const onText = vi.fn(); + const onThinking = vi.fn(); + const onToolStart = vi.fn(); + const { session } = await adapter.createSession({ onText, onThinking, onToolStart }); + + const promise = adapter.promptWithFallback(session, "hi"); stdout.write("[SandboxDebug] booting\n"); stdout.write("{not valid json\n"); - stdout.write(`${JSON.stringify({ type: "tool_use", stepNumber: 1, timestamp: 5, toolCall: {}, toolResult: {} })}\n`); - proc.emit("close", 0, null); + stdout.write(`${JSON.stringify({ type: "tool_use", toolCall: {}, toolResult: {} })}\n`); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); + closeProc(proc); + await promise; - await expect(promise).resolves.toBeUndefined(); expect(onText).not.toHaveBeenCalled(); + expect(onThinking).not.toHaveBeenCalled(); + expect(onToolStart).not.toHaveBeenCalled(); + expect(session.state.errorMessage).toBeUndefined(); }); it("resolves (never rejects) when the subprocess emits an error and records the diagnostic", async () => { @@ -133,13 +150,11 @@ describe("GrokRuntimeAdapter", () => { await Promise.resolve(); expect(resolved).toBe(false); - stderr.write("Error: API key required. Set GROK_API_KEY env var\n"); - proc.emit("close", 1, null); + stderr.write("error: invalid model 'grok-unknown'\n"); + closeProc(proc, 1); await promise; - expect(session.state.errorMessage).toBe( - "Grok CLI failed (code 1): Error: API key required. Set GROK_API_KEY env var", - ); + expect(session.state.errorMessage).toBe("Grok CLI failed (code 1): error: invalid model 'grok-unknown'"); }); it("records a concrete diagnostic for non-zero exits with no stderr", async () => { @@ -150,7 +165,7 @@ describe("GrokRuntimeAdapter", () => { const promise = adapter.promptWithFallback(session, "hi"); stdout.end(); - proc.emit("close", 2, null); + closeProc(proc, 2); await promise; expect(session.state.errorMessage).toBe("Grok CLI failed with code 2 and no stderr output."); @@ -165,11 +180,11 @@ describe("GrokRuntimeAdapter", () => { const promise = adapter.promptWithFallback(session, "hi"); stdout.end(); - proc.emit("close", 0, null); + closeProc(proc, 0); await promise; expect(session.state.errorMessage).toBe( - "Grok CLI produced no NDJSON output for a headless prompt; this usually means the binary on PATH is not the supported grok-cli headless implementation, did not recognize --prompt/--format json, or exited interactive mode immediately after stdin EOF.", + "Grok CLI produced no NDJSON output for a headless prompt; this usually means the binary on PATH is not xAI's supported Grok Build TUI headless implementation, did not recognize -p/--output-format streaming-json, or exited interactive mode immediately after stdin EOF.", ); expect(onText).toHaveBeenCalledWith(session.state.errorMessage); expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); @@ -185,7 +200,7 @@ describe("GrokRuntimeAdapter", () => { const promise = adapter.promptWithFallback(session, "hi"); stdout.write("Welcome to grok interactive mode\n"); stdout.end(); - proc.emit("close", 0, null); + closeProc(proc, 0); await promise; expect(session.state.errorMessage).toBe( @@ -194,35 +209,23 @@ describe("GrokRuntimeAdapter", () => { expect(onText).toHaveBeenCalledWith(session.state.errorMessage); }); - it("keeps a clean NDJSON run with no assistant text silent", async () => { + it("keeps a clean end event with no assistant text silent", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); + const onText = vi.fn(); + const { session } = await adapter.createSession({ onText }); const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "step_start", stepNumber: 1, timestamp: 1 })}\n`); - stdout.write( - `${JSON.stringify({ type: "step_finish", stepNumber: 1, timestamp: 2, finishReason: "stop", usage: {} })}\n`, - ); - proc.emit("close", 0, null); + stdout.write(`${JSON.stringify({ type: "thought", data: "No answer needed" })}\n`); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "session-empty" })}\n`); + closeProc(proc, 0); await promise; + expect(onText).not.toHaveBeenCalled(); expect(session.state.errorMessage).toBeUndefined(); - }); - - it("records well-formed NDJSON error events as diagnostics without rejecting", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const { session } = await adapter.createSession({}); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "error", message: "invalid model: grok-unknown", timestamp: 1 })}\n`); - proc.emit("close", 0, null); - await promise; - - expect(session.state.errorMessage).toBe("Grok CLI error: invalid model: grok-unknown"); + expect(session.state.messages).not.toContainEqual(expect.objectContaining({ role: "assistant" })); + expect(session.sessionId).toBe("session-empty"); }); it("does not turn a successful text response into an error when stderr is noisy", async () => { @@ -233,148 +236,35 @@ describe("GrokRuntimeAdapter", () => { const { session } = await adapter.createSession({ onText }); const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 1, text: "answer", timestamp: 1 })}\n`); + stdout.write(`${JSON.stringify({ type: "text", data: "answer" })}\n`); stderr.write("debug noise\n"); - proc.emit("close", 1, null); + closeProc(proc, 1); await promise; expect(onText).toHaveBeenCalledWith("answer"); expect(session.state.errorMessage).toBeUndefined(); }); - // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — tool_use bridging coverage. - it("bridges tool_use events into onToolStart/onToolEnd in order with translated args", async () => { + it("resolves on subprocess close rather than the end event alone", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); - - const onToolStart = vi.fn(); - const onToolEnd = vi.fn(); - const { session } = await adapter.createSession({ onToolStart, onToolEnd }); - - const promise = adapter.promptWithFallback(session, "list files"); - - stdout.write(`${JSON.stringify({ type: "step_start", stepNumber: 1, timestamp: 1 })}\n`); - stdout.write( - `${JSON.stringify({ - type: "tool_use", - stepNumber: 1, - timestamp: 2, - toolCall: { id: "tc-1", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } }, - toolResult: { success: true, output: "a.ts\nb.ts" }, - timing: { startedAt: 1, finishedAt: 2, durationMs: 1 }, - })}\n`, - ); - stdout.write( - `${JSON.stringify({ - type: "step_finish", - stepNumber: 1, - timestamp: 3, - finishReason: "tool_calls", - usage: {}, - })}\n`, - ); - proc.emit("close", 0, null); - - await promise; - - expect(onToolStart).toHaveBeenCalledTimes(1); - expect(onToolStart).toHaveBeenCalledWith("bash", { command: "ls" }); - expect(onToolEnd).toHaveBeenCalledTimes(1); - expect(onToolEnd).toHaveBeenCalledWith("bash", false, { success: true, output: "a.ts\nb.ts" }); - // onToolStart must fire before onToolEnd for the same tool call. - expect(onToolStart.mock.invocationCallOrder[0]).toBeLessThan(onToolEnd.mock.invocationCallOrder[0]); - }); - - it("marks onToolEnd as an error when toolResult.success is false", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onToolStart = vi.fn(); - const onToolEnd = vi.fn(); - const { session } = await adapter.createSession({ onToolStart, onToolEnd }); - - const promise = adapter.promptWithFallback(session, "read missing file"); - stdout.write( - `${JSON.stringify({ - type: "tool_use", - stepNumber: 1, - timestamp: 2, - toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"x"}' } }, - toolResult: { success: false, output: "ENOENT" }, - })}\n`, - ); - proc.emit("close", 0, null); - - await promise; - - expect(onToolEnd).toHaveBeenCalledWith("read_file", true, { success: false, output: "ENOENT" }); - }); - - it("handles malformed tool_use arguments without throwing, passing the raw string through", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onToolStart = vi.fn(); - const { session } = await adapter.createSession({ onToolStart }); + const { session } = await adapter.createSession({}); const promise = adapter.promptWithFallback(session, "hi"); - stdout.write( - `${JSON.stringify({ - type: "tool_use", - stepNumber: 1, - timestamp: 2, - toolCall: { id: "tc-3", type: "function", function: { name: "bash", arguments: "not-json" } }, - toolResult: { success: true }, - })}\n`, - ); - proc.emit("close", 0, null); - - await expect(promise).resolves.toBeUndefined(); - expect(onToolStart).toHaveBeenCalledWith("bash", "not-json"); - }); - - it("does not finalize on step_finish alone (per-step, not run-terminal); only close/error finalizes", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onText = vi.fn(); - const { session } = await adapter.createSession({ onText }); - - const promise = adapter.promptWithFallback(session, "multi-round"); let resolved = false; void promise.then(() => { resolved = true; }); - stdout.write( - `${JSON.stringify({ type: "step_finish", stepNumber: 1, timestamp: 1, finishReason: "tool_calls", usage: {} })}\n`, - ); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "EndTurn" })}\n`); await Promise.resolve(); await Promise.resolve(); expect(resolved).toBe(false); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 2, text: "done", timestamp: 2 })}\n`); - proc.emit("close", 0, null); + closeProc(proc, 0); await promise; - expect(resolved).toBe(true); - expect(onText).toHaveBeenCalledWith("done"); - }); - - it("never invokes onThinking: the verified grok-cli NDJSON schema has no thinking/reasoning event", async () => { - const { proc, stdout } = makeFakeProc(); - const spawn = vi.fn().mockReturnValue(proc); - const adapter = new GrokRuntimeAdapter({ spawn }); - const onThinking = vi.fn(); - const { session } = await adapter.createSession({ onThinking }); - - const promise = adapter.promptWithFallback(session, "hi"); - stdout.write(`${JSON.stringify({ type: "text", stepNumber: 1, text: "hi", timestamp: 1 })}\n`); - proc.emit("close", 0, null); - - await promise; - expect(onThinking).not.toHaveBeenCalled(); }); describe("lifecycle timeouts (fake timers)", () => { diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts index 60146ff5f7..031f1f57b0 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/stream-parser.test.ts @@ -2,65 +2,40 @@ import { describe, expect, it } from "vitest"; import { parseLine } from "../stream-parser.js"; /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: fixture lines below are copied verbatim in shape from upstream -grok-cli's `src/headless/output.test.ts` (the authoritative fixture-level -confirmation of the JSONL emitter's output), not invented. See -docs/grok-cli-contract.md for the full verified schema. +FNXC:GrokCli 2026-07-10-11:02: +FN-7790: fixtures use xAI Grok Build TUI's real `--output-format streaming-json` schema captured from the operator binary. Keep tests on `thought`/`text`/`end` so a future wrong-product `step_*`/`tool_use` assumption fails deterministically before production returns no messages again. */ -describe("parseLine (Grok CLI NDJSON)", () => { - it("parses a step_start event", () => { - const line = JSON.stringify({ type: "step_start", sessionID: "sess-1", stepNumber: 1, timestamp: 100 }); - expect(parseLine(line)).toEqual({ type: "step_start", sessionID: "sess-1", stepNumber: 1, timestamp: 100 }); +describe("parseLine (xAI Grok CLI streaming-json)", () => { + it("parses a thought event", () => { + const line = JSON.stringify({ type: "thought", data: "Thinking" }); + expect(parseLine(line)).toEqual({ type: "thought", data: "Thinking" }); }); - it("parses a text delta event", () => { - const line = JSON.stringify({ type: "text", sessionID: "sess-1", stepNumber: 1, text: "hello", timestamp: 150 }); - const parsed = parseLine(line); - expect(parsed).toEqual({ type: "text", sessionID: "sess-1", stepNumber: 1, text: "hello", timestamp: 150 }); + it("parses a text event", () => { + const line = JSON.stringify({ type: "text", data: "Hello" }); + expect(parseLine(line)).toEqual({ type: "text", data: "Hello" }); }); - it("parses a tool_use event", () => { + it("parses an end event", () => { const line = JSON.stringify({ - type: "tool_use", - sessionID: "sess-1", - stepNumber: 1, - timestamp: 130, - toolCall: { id: "tc-1", type: "function", function: { name: "bash", arguments: "{}" } }, - toolResult: { success: true, output: "ok" }, - timing: { startedAt: 110, finishedAt: 130, durationMs: 20 }, + type: "end", + stopReason: "EndTurn", + sessionId: "session-1", + requestId: "request-1", }); - const parsed = parseLine(line); - expect(parsed?.type).toBe("tool_use"); - expect((parsed as { toolCall: { function: { name: string } } }).toolCall.function.name).toBe("bash"); - }); - - it("parses a terminal step_finish event", () => { - const line = JSON.stringify({ - type: "step_finish", - sessionID: "sess-1", - stepNumber: 1, - timestamp: 200, - finishReason: "stop", - usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + expect(parseLine(line)).toEqual({ + type: "end", + stopReason: "EndTurn", + sessionId: "session-1", + requestId: "request-1", }); - const parsed = parseLine(line); - expect(parsed).toMatchObject({ type: "step_finish", finishReason: "stop" }); }); - it("parses an error event", () => { - const line = JSON.stringify({ type: "error", sessionID: "err-session", message: "boom", timestamp: 1 }); - expect(parseLine(line)).toEqual({ type: "error", sessionID: "err-session", message: "boom", timestamp: 1 }); - }); - - it("skips an empty line", () => { + it("skips empty and non-JSON lines", () => { expect(parseLine("")).toBeNull(); expect(parseLine(" ")).toBeNull(); - }); - - it("skips non-JSON debug output", () => { - expect(parseLine("[SandboxDebug] booting shuru vm")).toBeNull(); + expect(parseLine("[SandboxDebug] booting")).toBeNull(); }); it("skips malformed JSON without throwing", () => { @@ -68,45 +43,14 @@ describe("parseLine (Grok CLI NDJSON)", () => { expect(parseLine("{not valid json")).toBeNull(); }); - it("skips a JSON object with an unrecognized/missing type", () => { + it("skips missing, unknown, and legacy wrong-product event types", () => { expect(parseLine(JSON.stringify({ foo: "bar" }))).toBeNull(); expect(parseLine(JSON.stringify({ type: "some_future_event", data: 1 }))).toBeNull(); + expect(parseLine(JSON.stringify({ type: "step_start", stepNumber: 1 }))).toBeNull(); + expect(parseLine(JSON.stringify({ type: "tool_use", toolCall: {}, toolResult: {} }))).toBeNull(); }); - it("skips a JSON array (not an object)", () => { - expect(parseLine(JSON.stringify([{ type: "text" }]))).toBeNull(); - }); - - // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additional tool_use/step_finish/ - // error coverage for the runtime-adapter bridge (Step 3). The parser itself - // needed no change (see stream-parser.ts's FN-7724 comment); these prove - // the full toolCall/toolResult/timing shape round-trips and malformed tool - // lines are still skipped without throwing. - it("parses a tool_use event with full toolCall/toolResult/timing fields", () => { - const line = JSON.stringify({ - type: "tool_use", - sessionID: "sess-2", - stepNumber: 2, - timestamp: 300, - toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }, - toolResult: { success: false, output: "ENOENT" }, - timing: { startedAt: 280, finishedAt: 300, durationMs: 20 }, - }); - const parsed = parseLine(line); - expect(parsed).toEqual({ - type: "tool_use", - sessionID: "sess-2", - stepNumber: 2, - timestamp: 300, - toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }, - toolResult: { success: false, output: "ENOENT" }, - timing: { startedAt: 280, finishedAt: 300, durationMs: 20 }, - }); - }); - - it("skips a malformed tool_use line (broken JSON) without throwing", () => { - const line = '{"type":"tool_use","toolCall":{"function":{"name":'; - expect(() => parseLine(line)).not.toThrow(); - expect(parseLine(line)).toBeNull(); + it("skips a JSON array", () => { + expect(parseLine(JSON.stringify([{ type: "text", data: "hi" }]))).toBeNull(); }); }); diff --git a/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts b/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts index 6e62c18d40..0a1a39d47b 100644 --- a/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts +++ b/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts @@ -2,16 +2,8 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import type { Readable } from "node:stream"; /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: streaming spawn seam for `grok --prompt --format json` -(verified contract: docs/grok-cli-contract.md). `cli-spawn.ts`'s -`runGrokCommand` buffers stdout/stderr until process close, which cannot -host line-by-line NDJSON streaming for the real-time onText bridge this -adapter needs. This module is the line-streaming counterpart: same -Windows-shell handling as `cli-spawn.ts` (Windows `grok.cmd`/`grok.bat` PATH -shims need shell:true; Unix/macOS stay direct-spawned), same "no raw -detached spawn/nohup" foreground-pipe pattern. Mirrors the Droid plugin's -`spawnDroid` (process-manager.ts) shape. +FNXC:GrokCli 2026-07-10-10:49: +FN-7790: the operator-installed binary is xAI's Grok Build TUI (`grok 0.2.93`), not the previously assumed `superagent-ai/grok-cli`. Invoke the real headless contract, `grok -p --output-format streaming-json [-m ] [--cwd ]`; the old `--prompt`/`--format json`/`--directory` flags are rejected by the real binary and produce zero assistant text. Keep the existing foreground pipe and Windows shell handling so the adapter can stream line-by-line NDJSON without raw detached processes. */ export type GrokStreamProcess = ChildProcessByStdio; @@ -23,21 +15,21 @@ export interface SpawnGrokStreamOptions { } /** - * Spawn `grok --prompt --format json [--model ] [--directory ]` + * Spawn `grok -p --output-format streaming-json [-m ] [--cwd ]` * with piped stdio for line-by-line NDJSON consumption via readline. * * Does not read/buffer output itself — callers attach a `readline` interface * to `proc.stdout` (see `runtime-adapter.ts`). */ export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess { - const args: string[] = ["--prompt", prompt, "--format", "json"]; + const args: string[] = ["-p", prompt, "--output-format", "streaming-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 ` alongside `--prompt`/`--format json`. - args.push("--model", model); + // FNXC:GrokCliRouting 2026-07-10-10:49: FN-7790 keeps FN-7753's concrete `grok-cli/*` model preservation but uses xAI Grok Build TUI's accepted short flag, `-m `, with the provider prefix stripped by runtime-adapter.ts. + args.push("-m", model); } if (options?.cwd) { - args.push("--directory", options.cwd); + args.push("--cwd", options.cwd); } return spawn(binary, args, { diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index 079506e640..894430c814 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -1,48 +1,21 @@ import { createInterface } from "node:readline"; import { forceKillGrokStream, spawnGrokStream, type GrokStreamProcess, type SpawnGrokStreamOptions } from "./cli-stream.js"; import { parseLine } from "./stream-parser.js"; -import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, GrokErrorEvent, GrokSession } from "./types.js"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, GrokSession } from "./types.js"; /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: replaces the FN-7715 intentional no-op. Upstream grok-cli DOES -document and implement a non-interactive `grok --prompt --format -json` NDJSON event stream (verified against primary source, not just docs -prose: src/index.ts's CLI parsing + src/headless/output.ts's -`createHeadlessJsonlEmitter` + its fixture tests). Contract captured in -docs/grok-cli-contract.md. This adapter spawns that command via the -`cli-stream` seam, parses NDJSON via `stream-parser.parseLine`, and drives -`onText` as `text` events arrive. +FNXC:GrokCli 2026-07-10-10:54: +FN-7790: the production binary is xAI's Grok Build TUI, whose non-interactive prompt path is `grok -p --output-format streaming-json` and whose NDJSON union is `thought`/`text`/`end` with payloads in `data`. Bridge `text.data` to `onText`, `thought.data` to `onThinking`, and record `end.sessionId` without resolving before subprocess close, because close still carries stderr/exit diagnostics. The obsolete `step_*`/`tool_use`/`error` handling targeted a different `grok` product and is intentionally removed so tests cannot pass on the wrong schema again. -FNXC:GrokCli 2026-07-09-00:10: -FN-7724: extends the above with `tool_use` (and terminal `step_finish`/ -`error`) bridging, per docs/grok-cli-contract.md's verified NDJSON schema. -`onToolStart`/`onToolEnd` fire from each `tool_use` event's -`toolCall`/`toolResult`, mirroring the Droid plugin's `DroidCallbacks` -shape. No Grok→pi tool-name/arg mapping is applied: the verified contract -does not pin grok-cli's specific tool-name vocabulary (unlike Droid's -Claude-shaped names), so `toolCall.function.name`/parsed `.arguments` pass -through unchanged (decision recorded in the FN-7724 `research` task -document). `onThinking` is still never invoked — the verified schema has no -thinking/reasoning event (confirmed absence, not a gap). The terminal -lifecycle is UNCHANGED from FN-7722: the doc states `step_finish` is a -per-step boundary (multiple can occur per run for multi-round tool use), so -it does NOT finalize the promise here; only subprocess `close`/`error` -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/` or `grok/`) 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`. +FNXC:GrokCliRouting 2026-07-10-10:54: +FN-7753's auto-derived `grok` runtime routing from a `grok-cli/*` model selection still preserves the concrete model. Normalize provider-qualified ids (`grok-cli/` or `grok/`) at session creation/prompt time and pass only the concrete id to `grok -m`; the no-model Runtime-mode path keeps the historical `grok/default` session fallback and omits `-m`. */ /** - * Cold-start ceiling: if `grok --prompt --format json` produces no stdout - * line within this window, treat it as a hung/failed subprocess and resolve - * (never reject — mirrors the Droid adapter's resolve-on-error lifecycle so - * pi always gets a well-formed, if empty, result instead of an unhandled - * rejection). + * Cold-start ceiling: if `grok -p --output-format streaming-json` produces no + * stdout line within this window, treat it as a hung/failed subprocess and + * resolve (never reject — mirrors the Droid adapter's resolve-on-error lifecycle + * so pi always gets a well-formed, if empty, result instead of an unhandled rejection). */ const FIRST_LINE_TIMEOUT_MS = 60_000; @@ -55,25 +28,6 @@ const FIRST_LINE_TIMEOUT_MS = 60_000; */ const INACTIVITY_TIMEOUT_MS = 30 * 60_000; -/** - * FNXC:GrokCli 2026-07-09-00:10: - * FN-7724: `toolCall.function.arguments` is a JSON-encoded string per the - * verified `ToolCall` shape (docs/grok-cli-contract.md / types.ts's - * `GrokToolCallLike`). Parse it defensively — malformed/missing arguments - * must never throw inside the NDJSON read loop; fall back to the raw string - * (or undefined) so callers still see something rather than losing the - * event, mirroring the Droid event-bridge's empty-args guard. - */ -function parseToolArguments(raw: string | undefined): unknown { - if (raw === undefined) return undefined; - if (raw === "") return {}; - try { - return JSON.parse(raw); - } catch { - return raw; - } -} - function normalizeGrokCliModel(model: string | undefined): string | undefined { const normalized = model?.trim(); if (!normalized) return undefined; @@ -101,17 +55,12 @@ function formatCloseDiagnostic(code: number | null, signal: NodeJS.Signals | nul return detail ? `Grok CLI failed (${exitDetail}): ${detail}` : `Grok CLI failed with ${exitDetail} and no stderr output.`; } -function formatErrorEventDiagnostic(event: GrokErrorEvent): string { - const detail = compactDiagnostic(event.message); - return detail ? `Grok CLI error: ${detail}` : "Grok CLI emitted an error event without a message."; -} - function formatNoNdjsonDiagnostic(firstStdoutLine: string | undefined): string { const firstLine = firstStdoutLine ? compactDiagnostic(firstStdoutLine) : ""; if (firstLine) { return `Grok CLI produced stdout but no NDJSON events for a headless prompt; first line: ${firstLine}`; } - return "Grok CLI produced no NDJSON output for a headless prompt; this usually means the binary on PATH is not the supported grok-cli headless implementation, did not recognize --prompt/--format json, or exited interactive mode immediately after stdin EOF."; + return "Grok CLI produced no NDJSON output for a headless prompt; this usually means the binary on PATH is not xAI's supported Grok Build TUI headless implementation, did not recognize -p/--output-format streaming-json, or exited interactive mode immediately after stdin EOF."; } function appendMessage(session: GrokSession, role: "user" | "assistant", content: string): void { @@ -217,8 +166,8 @@ export class GrokRuntimeAdapter implements AgentRuntime { FNXC:GrokCli 2026-07-10-00:00: A failing headless `grok` run can close stdout before the child `close` event reports its non-zero exit and stderr. Resolving on readline close made dashboard Chat persist an empty assistant message before the diagnostic existed. Finalize only from subprocess close/error or lifecycle timeouts, and store concrete stderr/NDJSON error details on session.state.errorMessage so shared chat/executor seams can surface the reason without breaking the resolve-never-reject runtime contract. - FNXC:GrokCli 2026-07-10-09:55: - FN-7788 root-caused the remaining immediate "no message" symptom to a code-0 prompt run that emitted no parsed NDJSON at all, commonly caused by an unsupported/wrong `grok` binary falling into interactive mode and immediately reading EOF from ignored stdin. Upstream guarantees a valid `grok --prompt --format json` run emits at least `step_start`, so a zero-NDJSON close is now a diagnosable failure surfaced through both `onText` and `session.state.errorMessage`; only a real NDJSON run with empty assistant text stays silent. + FNXC:GrokCli 2026-07-10-10:56: + FN-7790 keeps FN-7788's zero-output diagnostic but updates the invariant for xAI Grok Build TUI: a valid `grok -p --output-format streaming-json` run emits at least an `end` event, with optional `thought`/`text` events. A code-0 close with zero parsed NDJSON is a wrong-binary/interactive-EOF failure surfaced through both `onText` and `session.state.errorMessage`; a real `end` event with empty assistant text remains a legitimate silent response. */ const finish = () => { if (settled) return; @@ -268,31 +217,19 @@ export class GrokRuntimeAdapter implements AgentRuntime { receivedNdjsonEvent = true; if (event.type === "text") { - if (event.text.length > 0) { + if (event.data.length > 0) { receivedText = true; - assistantText += event.text; + assistantText += event.data; } - grokSession.callbacks.onText?.(event.text); - } else if (event.type === "tool_use") { - // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified - // tool_use event. toolCall.function.name/arguments and - // toolResult.success/output are the verified fields - // (docs/grok-cli-contract.md); pass-through, no name/arg mapping - // (see FN-7724 research task document for the decision). - const toolName = event.toolCall?.function?.name ?? event.toolCall?.type ?? "unknown"; - const args = parseToolArguments(event.toolCall?.function?.arguments); - grokSession.callbacks.onToolStart?.(toolName, args); - const isError = event.toolResult?.success === false; - grokSession.callbacks.onToolEnd?.(toolName, isError, event.toolResult); - } else if (event.type === "error") { - setErrorMessage(formatErrorEventDiagnostic(event)); + grokSession.callbacks.onText?.(event.data); + } else if (event.type === "thought") { + grokSession.callbacks.onThinking?.(event.data); + } else if (event.type === "end") { + grokSession.sessionId = event.sessionId ?? grokSession.sessionId; } - // step_start / step_finish: step_finish is a per-step boundary (not - // run-terminal, per docs/grok-cli-contract.md — a run can have - // multiple step_start/step_finish pairs for multi-round tool use), so - // it is intentionally NOT bridged into a callback or treated as the - // finalize signal; only subprocess close/error finalizes (see finish() - // below). + // `end` is the real xAI stream's terminal marker, but subprocess close + // remains authoritative for resolving because close carries non-zero + // exit/stderr diagnostics for failed runs. }); proc.stderr?.on("data", (chunk: Buffer | string) => { diff --git a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts index 4c7d4bf24c..08dabe8133 100644 --- a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts +++ b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts @@ -1,33 +1,16 @@ import type { GrokNdjsonEvent } from "./types.js"; /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: `grok --prompt --format json` emits newline-delimited JSON -(one JSON object per line) per the verified upstream contract captured in -docs/grok-cli-contract.md (source: src/headless/output.ts's -`createHeadlessJsonlEmitter` / `HeadlessJsonEvent`). This parser mirrors the -Droid plugin's `stream-parser.ts` shape and resilience contract: it never -throws. Debug noise, empty lines, and malformed/unrecognized JSON all return -null so the streaming pipeline can safely skip them and continue. +FNXC:GrokCli 2026-07-10-10:50: +FN-7790: xAI's official Grok Build TUI streams newline-delimited `thought`/`text`/`end` JSON from `grok -p --output-format streaming-json`. The previously accepted `step_*`/`tool_use`/`error` events described a different `grok` binary and masked production no-message failures, so unknown legacy lines now fall through as unrecognized while the parser keeps its never-throw resilience. */ - -/* -FNXC:GrokCli 2026-07-09-00:10: -FN-7724: confirmed at execution time — FN-7722 already typed `tool_use` / -`step_finish` / `error` into the `GrokNdjsonEvent` union (types.ts) and this -parser already accepts them via KNOWN_EVENT_TYPES below, so no parser change -was needed to "surface" them; only runtime-adapter.ts's bridge (previously -intentionally dropping tool_use/step_finish/error, see FN-7722 comment -above) needed extending. See docs/grok-cli-contract.md for the verified -schema this parser accepts unmodified. -*/ -const KNOWN_EVENT_TYPES = new Set(["step_start", "text", "tool_use", "step_finish", "error"]); +const KNOWN_EVENT_TYPES = new Set(["thought", "text", "end"]); /** - * Parse a single NDJSON line from `grok --prompt --format json` stdout into a + * Parse a single NDJSON line from `grok -p --output-format streaming-json` stdout into a * typed event, or null when the line should be skipped (empty, non-JSON * debug noise, malformed JSON, or a JSON object whose `type` isn't one of - * the five verified event types). + * the real xAI streaming event types). */ export function parseLine(line: string): GrokNdjsonEvent | null { const trimmed = line.trim(); diff --git a/plugins/fusion-plugin-grok-runtime/src/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index b792917df7..253244c7eb 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -1,105 +1,35 @@ /* -FNXC:GrokCli 2026-07-09-00:00: -FN-7722: additive types for the real (non-no-op) `GrokRuntimeAdapter` -streaming implementation. `GrokNdjsonEvent` mirrors the VERIFIED -`HeadlessJsonEvent` union from upstream grok-cli's `src/headless/output.ts` -(captured in docs/grok-cli-contract.md) — there is deliberately no -thinking/reasoning event type here because upstream's JSONL emitter never -surfaces one (confirmed absence, not an omission). `GrokSession` / -`GrokCallbacks` / `AgentRuntime*` mirror the Droid plugin's `types.ts` shape -so this adapter satisfies the same plugin runtime contract -(`packages/engine/src/runtime-resolution.ts`'s `resolveRuntime`). Additive -only — does not collide with FN-7716's `GrokBinaryStatus` fields below. +FNXC:GrokCli 2026-07-10-10:48: +FN-7790: operators run xAI's official Grok Build TUI (`grok 0.2.93`), not the previously assumed `superagent-ai/grok-cli` product. The real headless stream is `grok -p --output-format streaming-json` and emits `thought`/`text`/`end` objects with `data`, so these types intentionally retire the old `step_*`/`tool_use`/`error` union that made fake tests pass while the real binary returned no assistant text. */ -export interface GrokToolCallLike { - id?: string; - type?: string; - function?: { name?: string; arguments?: string }; - [key: string]: unknown; -} - -export interface GrokToolResultLike { - success?: boolean; - output?: string; - [key: string]: unknown; -} - -export interface GrokStepStartEvent { - type: "step_start"; - sessionID?: string; - stepNumber: number; - timestamp: number; +export interface GrokThoughtEvent { + type: "thought"; + data: string; } export interface GrokTextEvent { type: "text"; - sessionID?: string; - stepNumber: number; - text: string; - timestamp: number; + data: string; } -export interface GrokToolUseEvent { - type: "tool_use"; - sessionID?: string; - stepNumber: number; - timestamp: number; - toolCall: GrokToolCallLike; - toolResult: GrokToolResultLike; - timing?: { startedAt?: number; finishedAt?: number; durationMs?: number }; +export interface GrokEndEvent { + type: "end"; + stopReason?: string; + sessionId?: string; + requestId?: string; } -export interface GrokStepFinishEvent { - type: "step_finish"; - sessionID?: string; - stepNumber: number; - timestamp: number; - finishReason: string; - usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; costUsdTicks?: number }; -} - -export interface GrokErrorEvent { - type: "error"; - sessionID?: string; - message: string; - timestamp: number; -} - -export type GrokNdjsonEvent = - | GrokStepStartEvent - | GrokTextEvent - | GrokToolUseEvent - | GrokStepFinishEvent - | GrokErrorEvent; +export type GrokNdjsonEvent = GrokThoughtEvent | GrokTextEvent | GrokEndEvent; export interface GrokCallbacks { + /** Streams real assistant text from xAI Grok Build TUI `text.data` events. */ onText?: (text: string) => void; - /** - * FNXC:GrokCli 2026-07-09-00:00: kept for AgentRuntime interface parity - * with the Droid/Cursor plugins, but never invoked by this adapter — - * upstream grok-cli's `--format json` stream has no thinking/reasoning - * event to bridge (see docs/grok-cli-contract.md). - */ + /** Streams reasoning/thinking text from xAI Grok Build TUI `thought.data` events. */ onThinking?: (text: string) => void; - /** - * FNXC:GrokCli 2026-07-09-00:10: - * FN-7724: bridged from the verified `tool_use` NDJSON event's - * `toolCall.function.name` / parsed `toolCall.function.arguments`. - * Mirrors the Droid plugin's `DroidCallbacks.onToolStart` signature. No - * Grok→pi tool-name mapping is applied — the verified contract - * (docs/grok-cli-contract.md) does not pin grok-cli's specific tool-name - * vocabulary, so names/args pass through unchanged (see FN-7724 research - * task document for the decision). - */ + /** Kept for AgentRuntime interface parity; xAI `streaming-json` has no observed tool-use event. */ onToolStart?: (toolName: string, args?: unknown) => void; - /** - * FNXC:GrokCli 2026-07-09-00:10: - * FN-7724: bridged from the same `tool_use` event's `toolResult` field — - * `isError` derives from the verified `toolResult.success === false`, - * `result` is the full `toolResult` object (includes `output` plus any - * other verified/unverified passthrough fields). - */ + /** Kept for AgentRuntime interface parity; xAI `streaming-json` has no observed tool-use event. */ onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; } @@ -121,9 +51,7 @@ export interface AgentRuntimeOptions { defaultModelId?: string; onText?: (text: string) => void; onThinking?: (text: string) => void; - /** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolStart. */ onToolStart?: (toolName: string, args?: unknown) => void; - /** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolEnd. */ onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; signal?: AbortSignal; }