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