From c258fc1590e00fd63b80365135b048df1a209bbe Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 12:57:41 -0700 Subject: [PATCH] FN-7796: switch Grok CLI headless prompts to the reliable single-object JSON output Narrative: the streaming-json headless contract intermittently emitted only thought events then stopReason:Cancelled with zero text, leaving Chat replies silently empty; the adapter now spawns grok with --output-format json, buffers stdout, and parses the single JSON response on process close, with streaming-json parsing kept only as a diagnostic fallback. - Change grok CLI invocation from --output-format streaming-json to --output-format json (cli-stream.ts) - Add GrokCliJsonResponse type ({text, stopReason, sessionId, requestId, thought}) and parseJsonOutput() to stream-parser.ts, keeping legacy NDJSON line parsing for fallback/diagnostics - Rework runtime-adapter.ts to buffer full stdout, parse it via parsePromptOutput (JSON object first, NDJSON fallback), and surface a formatTerminalNoTextDiagnostic when a non-EndTurn stopReason yields no assistant text - Rename first-line/inactivity timeout bookkeeping from line-based to output/chunk-based (FIRST_OUTPUT_TIMEOUT_MS, firstOutputReceived, firstStdoutChunk) since stdout is no longer consumed via readline - Update cli-stream/runtime-adapter/stream-parser tests to cover the JSON response path and the Cancelled/no-text diagnostic - Update docs/grok-cli-contract.md and plugin README to document the json output-format contract and diagnostics - Add changeset fn-7796-grok-cli-reliable-headless.md (patch, fix) Files changed: .changeset/fn-7796-grok-cli-reliable-headless.md | 7 + docs/grok-cli-contract.md | 108 ++++++++----- plugins/fusion-plugin-grok-runtime/README.md | 16 +- .../src/__tests__/cli-stream.test.ts | 4 +- .../src/__tests__/runtime-adapter.test.ts | 73 ++++++++- .../src/__tests__/stream-parser.test.ts | 80 +++++---- .../fusion-plugin-grok-runtime/src/cli-stream.ts | 14 +- .../src/runtime-adapter.ts | 180 ++++++++++++--------- .../src/stream-parser.ts | 65 ++++++-- plugins/fusion-plugin-grok-runtime/src/types.ts | 13 +- 10 files changed, 373 insertions(+), 187 deletions(-) Fusion-Task-Id: FN-7796 Fusion-Task-Lineage: c920fcf0-98f8-42ec-867a-7f76c0aca1b7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7796-grok-cli-reliable-headless.md | 7 + docs/grok-cli-contract.md | 108 +++++++---- plugins/fusion-plugin-grok-runtime/README.md | 16 +- .../src/__tests__/cli-stream.test.ts | 4 +- .../src/__tests__/runtime-adapter.test.ts | 73 ++++++- .../src/__tests__/stream-parser.test.ts | 80 +++++--- .../src/cli-stream.ts | 14 +- .../src/runtime-adapter.ts | 178 ++++++++++-------- .../src/stream-parser.ts | 73 ++++--- .../fusion-plugin-grok-runtime/src/types.ts | 13 +- 10 files changed, 376 insertions(+), 190 deletions(-) create mode 100644 .changeset/fn-7796-grok-cli-reliable-headless.md diff --git a/.changeset/fn-7796-grok-cli-reliable-headless.md b/.changeset/fn-7796-grok-cli-reliable-headless.md new file mode 100644 index 0000000000..3dbe2536f0 --- /dev/null +++ b/.changeset/fn-7796-grok-cli-reliable-headless.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Grok CLI chat replies reliable by using the stable headless JSON response. +category: fix +dev: Grok runtime now invokes `grok -p --output-format json` and diagnoses empty non-EndTurn results. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index 745bbd2633..6cab531cd1 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -1,10 +1,10 @@ -# Grok CLI Contract (FN-7790) +# Grok CLI Contract (FN-7790, updated by FN-7796) Date: 2026-07-10 ## Ground truth @@ -21,7 +21,9 @@ External integration evidence: 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 +## Failures that shaped the contract + +### Wrong-product flags (FN-7790) The old adapter invocation fails against the real xAI binary: @@ -42,28 +44,77 @@ error: unexpected argument '--prompt' found Usage: grok --prompt-file [PROMPT] ``` -Because no NDJSON `text` event is produced, Fusion surfaced a blank/no-message assistant response. +Because no renderable assistant text is produced, Fusion surfaced a blank/no-message assistant response. -## Confirmed non-interactive invocation +### Streaming JSON cancellation with zero text (FN-7796) -Use xAI Grok Build TUI's single-turn prompt mode with streaming JSON: +FN-7790 correctly switched to xAI's real flags and streaming event union, but live triage found `--output-format streaming-json` is intermittently unreliable. The same authenticated `grok 0.2.93` binary sometimes emits only reasoning events, then ends with `stopReason:"Cancelled"` and no `text` event while still exiting 0 with empty stderr. + +Live-captured shape: + +```jsonl +{"type":"thought","data":"..."} +{"type":"thought","data":"..."} +{"type":"end","stopReason":"Cancelled","sessionId":"...","requestId":"..."} +``` + +The adapter previously saw parsed events and a successful close, accumulated empty assistant text, set no error, and produced a silent no-message bubble. The reliable replacement is the single-object JSON contract below. + +## Confirmed non-interactive invocation used by Fusion + +Use xAI Grok Build TUI's single-turn prompt mode with **single-object JSON**: ```bash -grok -p "" --output-format streaming-json +grok -p "" --output-format json # equivalent long prompt flag: -grok --single "" --output-format streaming-json +grok --single "" --output-format json ``` Supported companion flags used by Fusion: - `-p, --single ` — run a single prompt, print the response, and exit. This does not require interactive stdin. -- `--output-format ` — streaming adapter uses `streaming-json`. +- `--output-format ` — Fusion uses `json` for reliable headless prompts. - `-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. 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 +## Reliable JSON response schema + +`--output-format json` emits one final JSON object rather than an NDJSON stream. Observed shape: + +```ts +interface GrokJsonResponse { + text?: string; + stopReason?: string; + sessionId?: string; + requestId?: string; + thought?: string; +} +``` + +Example: + +```json +{ + "text": "Hello", + "stopReason": "EndTurn", + "sessionId": "019f4d81-8fb1-7f11-98ca-5ae00654b518", + "requestId": "bb7952e2-f1bc-4574-b409-5cc568817fe5", + "thought": "The user wants me to say hello in one word..." +} +``` + +Mapping in Fusion: + +- `thought` → `onThinking(thought)` when non-empty. +- `text` → `onText(text)` and accumulated assistant content when non-empty. +- `sessionId` → `session.sessionId` when present. +- subprocess `close` remains the authoritative promise resolution point because it carries exit status/stderr diagnostics. + +Live reliability evidence from FN-7796: `grok -p "say hello in one word" --output-format json` returned real text with `stopReason:"EndTurn"` on 4/4 direct runs, and the built `GrokRuntimeAdapter` carried real text through `onText`/persisted assistant content on 3/3 end-to-end runs against the real binary. + +## Streaming JSON event schema (not the primary prompt path) `--output-format streaming-json` emits one JSON object per line: @@ -74,13 +125,7 @@ type GrokStreamingJsonEvent = | { type: "end"; stopReason?: string; sessionId?: string; requestId?: string }; ``` -Mapping in Fusion: - -- `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: +Successful captured tail: ```jsonl {"type":"thought","data":" one"} @@ -93,25 +138,11 @@ Real captured tail: {"type":"end","stopReason":"EndTurn","sessionId":"019f4d1e-2582-70e0-a174-c8774782ab01","requestId":"2233f1dc-e9ad-4ae4-8221-caa6afade07f"} ``` -A successful run exits 0 with empty stderr. +Fusion does not use streaming-json as the primary headless prompt path because it intermittently produces the cancelled/no-text shape documented above. Parser support remains only to keep diagnostics and regression tests concrete if captured streaming output appears in buffered stdout. -## Non-streaming formats +## Other output formats -`--output-format plain` prints renderable response text. - -`--output-format json` emits one final JSON object rather than an NDJSON stream. Observed shape: - -```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..." -} -``` - -Fusion uses `streaming-json` for live `onText`/`onThinking` callbacks. +`--output-format plain` prints renderable response text, but does not expose `sessionId`, `requestId`, `stopReason`, or `thought`. ## Model discovery @@ -131,7 +162,7 @@ Fusion parses the bullet list conservatively and exposes ids under provider `gro ## Auth and readiness -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. +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 json` run. 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. @@ -150,8 +181,9 @@ The adapter preserves the resolve-never-reject runtime contract while surfacing - 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. +- code-0 close with no parseable JSON response → wrong-binary/interactive-EOF diagnostic. +- parseable response with no text and `stopReason !== "EndTurn"` → stop-reason diagnostic, e.g. `Grok CLI ended with stopReason Cancelled and produced no assistant text.` +- parseable `EndTurn` response with no 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. 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 4f0542d724..58894e86a1 100644 --- a/plugins/fusion-plugin-grok-runtime/README.md +++ b/plugins/fusion-plugin-grok-runtime/README.md @@ -23,23 +23,23 @@ The previously assumed `superagent-ai/grok-cli` contract is a different product - **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-7790) +## CLI headless execution path (FN-7790 / FN-7796) -The plugin's `GrokRuntimeAdapter` streams a real Grok response through xAI's CLI: +The plugin's `GrokRuntimeAdapter` returns a real Grok response through xAI's reliable single-object CLI output: ```bash -grok -p "" --output-format streaming-json +grok -p "" --output-format json # with optional model/cwd: -grok -p "" --output-format streaming-json -m "grok-4.5" --cwd "/path/to/project" +grok -p "" --output-format json -m "grok-4.5" --cwd "/path/to/project" ``` - `-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. +- `--output-format json` emits one object with `{ text, stopReason, sessionId, requestId, thought }`. Fusion buffers stdout until subprocess `close`, then bridges `thought` to `onThinking`, `text` to `onText` and persisted assistant content, and stores `sessionId` when present. +- xAI's `--output-format streaming-json` mode is not used for the primary headless path because live `grok 0.2.93` testing found it can intermittently end `stopReason:"Cancelled"` with zero `text` events. A non-`EndTurn` stop reason with empty text now surfaces a concrete diagnostic instead of a blank assistant response; a parseable `EndTurn` with empty text remains a legitimate silent response. +- A wrong-binary/wrong-flag run that emits no parseable JSON surfaces a concrete diagnostic instead of a blank assistant 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. -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. +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 or the flaky streaming-json prompt path. ## Routing Grok through the CLI runtime (FN-7725 / FN-7753 / FN-7790) 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 6789d215a9..4941cb2fa7 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 @@ -42,7 +42,7 @@ describe("spawnGrokStream", () => { "-p", "hello", "--output-format", - "streaming-json", + "json", "-m", "grok-4.5", "--cwd", @@ -62,7 +62,7 @@ describe("spawnGrokStream", () => { "-p", "hello", "--output-format", - "streaming-json", + "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 4b5411d0e8..b47619d06a 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 @@ -5,8 +5,8 @@ import type { GrokStreamProcess } from "../cli-stream.js"; import { GrokRuntimeAdapter } from "../runtime-adapter.js"; /* -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. +FNXC:GrokCli 2026-07-10-12:54: +FN-7796: adapter tests are pinned to the reliable xAI Grok Build TUI headless contract (`--output-format json` single object) and the live-captured flaky `streaming-json` cancellation shape. They intentionally avoid a live binary in CI but exercise the same spawn seam and lifecycle diagnostics that previously hid wrong-contract and cancelled-no-text failures behind fake fixtures. */ function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: PassThrough; kill: ReturnType } { @@ -58,6 +58,65 @@ describe("GrokRuntimeAdapter", () => { expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: undefined })); }); + + it("bridges the reliable single-object json response 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 onThinking = vi.fn(); + const { session } = await adapter.createSession({ onText, onThinking }); + + const promise = adapter.promptWithFallback(session, "hello grok"); + stdout.write(JSON.stringify({ text: "Hello", stopReason: "EndTurn", sessionId: "session-json", requestId: "request-json", thought: "Thinking" })); + stdout.end(); + closeProc(proc); + await promise; + + expect(onThinking).toHaveBeenCalledWith("Thinking"); + expect(onText).toHaveBeenCalledWith("Hello"); + expect(session.sessionId).toBe("session-json"); + expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); + }); + + it("surfaces cancelled no-text json object as a diagnostic instead of a silent empty response", 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, "say hello in one word"); + stdout.write(JSON.stringify({ text: "", stopReason: "Cancelled", sessionId: "session-cancelled" })); + stdout.end(); + closeProc(proc); + await promise; + + expect(session.state.errorMessage).toBe("Grok CLI ended with stopReason Cancelled and produced no assistant text."); + expect(onText).toHaveBeenCalledWith(session.state.errorMessage); + expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); + }); + + it("surfaces cancelled no-text streaming-json shape as a diagnostic instead of a silent empty response", 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 { session } = await adapter.createSession({ onText, onThinking }); + + const promise = adapter.promptWithFallback(session, "say hello in one word"); + stdout.write(`${JSON.stringify({ type: "thought", data: "Thinking" })}\n`); + stdout.write(`${JSON.stringify({ type: "end", stopReason: "Cancelled", sessionId: "session-cancelled", requestId: "request-cancelled" })}\n`); + stdout.end(); + closeProc(proc); + await promise; + + expect(session.state.errorMessage).toBe("Grok CLI ended with stopReason Cancelled and produced no assistant text."); + expect(onText).toHaveBeenCalledWith(session.state.errorMessage); + expect(session.state.messages).toContainEqual({ role: "assistant", content: session.state.errorMessage }); + }); + it("bridges real xAI thought/text/end events and persists assistant content", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); @@ -75,7 +134,7 @@ describe("GrokRuntimeAdapter", () => { await promise; expect(onThinking.mock.calls.map((c) => c[0])).toEqual(["Thinking"]); - expect(onText.mock.calls.map((c) => c[0])).toEqual(["Hel", "lo"]); + expect(onText.mock.calls.map((c) => c[0])).toEqual(["Hello"]); expect(session.sessionId).toBe("session-1"); expect(session.state.messages).toContainEqual({ role: "assistant", content: "Hello" }); }); @@ -171,7 +230,7 @@ describe("GrokRuntimeAdapter", () => { expect(session.state.errorMessage).toBe("Grok CLI failed with code 2 and no stderr output."); }); - it("records a concrete diagnostic for code-0 exits with zero NDJSON output", async () => { + it("records a concrete diagnostic for code-0 exits with zero JSON output", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); @@ -184,13 +243,13 @@ describe("GrokRuntimeAdapter", () => { 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 xAI's supported Grok Build TUI headless implementation, did not recognize -p/--output-format streaming-json, or exited interactive mode immediately after stdin EOF.", + "Grok CLI produced no JSON 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 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 }); }); - it("records a concrete diagnostic for code-0 exits with non-NDJSON stdout only", async () => { + it("records a concrete diagnostic for code-0 exits with non-JSON stdout only", async () => { const { proc, stdout } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); @@ -204,7 +263,7 @@ describe("GrokRuntimeAdapter", () => { await promise; expect(session.state.errorMessage).toBe( - "Grok CLI produced stdout but no NDJSON events for a headless prompt; first line: Welcome to grok interactive mode", + "Grok CLI produced stdout but no parseable JSON response for a headless prompt; first output: Welcome to grok interactive mode", ); expect(onText).toHaveBeenCalledWith(session.state.errorMessage); }); 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 031f1f57b0..f113363969 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 @@ -1,56 +1,80 @@ import { describe, expect, it } from "vitest"; -import { parseLine } from "../stream-parser.js"; +import { parseJsonOutput, parseLine } from "../stream-parser.js"; /* -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. +FNXC:GrokCli 2026-07-10-12:53: +FN-7796: fixtures pin the reliable xAI Grok Build TUI headless contract, `--output-format json`, because live `streaming-json` intermittently ended `stopReason:"Cancelled"` without text. Keep one streaming parser regression for the captured cancelled shape so diagnostics stay concrete if the flaky shape appears in buffered output. */ -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" }); +describe("parseJsonOutput (xAI Grok CLI json)", () => { + it("parses the reliable single-object response", () => { + const output = JSON.stringify({ + text: "Hello", + stopReason: "EndTurn", + sessionId: "session-1", + requestId: "request-1", + thought: "Thinking", + }); + expect(parseJsonOutput(output)).toEqual({ + text: "Hello", + stopReason: "EndTurn", + sessionId: "session-1", + requestId: "request-1", + thought: "Thinking", + }); }); - it("parses a text event", () => { - const line = JSON.stringify({ type: "text", data: "Hello" }); - expect(parseLine(line)).toEqual({ type: "text", data: "Hello" }); + it("tolerates pretty-printed json from the real CLI", () => { + const output = `\n{\n "text": "Hello",\n "stopReason": "EndTurn",\n "sessionId": "session-1",\n "requestId": "request-1",\n "thought": "Thinking"\n}\n`; + expect(parseJsonOutput(output)?.text).toBe("Hello"); }); - it("parses an end event", () => { + it("preserves a terminal empty EndTurn object", () => { + expect(parseJsonOutput(JSON.stringify({ text: "", stopReason: "EndTurn" }))).toEqual({ + text: "", + stopReason: "EndTurn", + sessionId: undefined, + requestId: undefined, + thought: undefined, + }); + }); + + it("skips empty, non-JSON, malformed JSON, arrays, and unrelated objects without throwing", () => { + expect(parseJsonOutput("")).toBeNull(); + expect(parseJsonOutput("Welcome to grok interactive mode")).toBeNull(); + expect(() => parseJsonOutput("{not valid json")).not.toThrow(); + expect(parseJsonOutput("{not valid json")).toBeNull(); + expect(parseJsonOutput(JSON.stringify([{ text: "hi" }]))).toBeNull(); + expect(parseJsonOutput(JSON.stringify({ type: "step_start" }))).toBeNull(); + }); +}); + +describe("parseLine (captured flaky streaming-json diagnostics)", () => { + it("parses the cancelled no-text terminal shape", () => { const line = JSON.stringify({ type: "end", - stopReason: "EndTurn", + stopReason: "Cancelled", sessionId: "session-1", requestId: "request-1", }); expect(parseLine(line)).toEqual({ type: "end", - stopReason: "EndTurn", + stopReason: "Cancelled", sessionId: "session-1", requestId: "request-1", }); }); - it("skips empty and non-JSON lines", () => { + it("parses thought/text events for buffered streaming regressions", () => { + expect(parseLine(JSON.stringify({ type: "thought", data: "Thinking" }))).toEqual({ type: "thought", data: "Thinking" }); + expect(parseLine(JSON.stringify({ type: "text", data: "Hello" }))).toEqual({ type: "text", data: "Hello" }); + }); + + it("skips malformed, unknown, and legacy wrong-product lines", () => { expect(parseLine("")).toBeNull(); - expect(parseLine(" ")).toBeNull(); expect(parseLine("[SandboxDebug] booting")).toBeNull(); - }); - - it("skips malformed JSON without throwing", () => { - expect(() => parseLine("{not valid json")).not.toThrow(); expect(parseLine("{not valid json")).toBeNull(); - }); - - 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", () => { 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 0a1a39d47b..18f443cc17 100644 --- a/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts +++ b/plugins/fusion-plugin-grok-runtime/src/cli-stream.ts @@ -2,8 +2,8 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import type { Readable } from "node:stream"; /* -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. +FNXC:GrokCli 2026-07-10-12:50: +FN-7796: the operator-installed binary is xAI's Grok Build TUI (`grok 0.2.93`). Its `--output-format streaming-json` mode intermittently ends `stopReason:"Cancelled"` with zero `text` events, so Fusion's headless prompt path uses the reliable single-object contract, `grok -p --output-format json [-m ] [--cwd ]`. Keep foreground piped stdio and Windows shell handling so the adapter can buffer stdout, parse the object on close, and surface close/stderr diagnostics without raw detached processes. */ export type GrokStreamProcess = ChildProcessByStdio; @@ -15,14 +15,12 @@ export interface SpawnGrokStreamOptions { } /** - * 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`). + * Spawn `grok -p --output-format json [-m ] [--cwd ]` + * with piped stdio. The adapter buffers stdout and parses the complete + * single-object response on subprocess close. */ export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess { - const args: string[] = ["-p", prompt, "--output-format", "streaming-json"]; + const args: string[] = ["-p", prompt, "--output-format", "json"]; const model = options?.model?.trim(); if (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. diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index 894430c814..d63c695f75 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -1,27 +1,26 @@ -import { createInterface } from "node:readline"; import { forceKillGrokStream, spawnGrokStream, type GrokStreamProcess, type SpawnGrokStreamOptions } from "./cli-stream.js"; -import { parseLine } from "./stream-parser.js"; +import { parseJsonOutput, parseLine } from "./stream-parser.js"; import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, GrokSession } from "./types.js"; /* -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-10-12:52: +FN-7796: the production binary is xAI's Grok Build TUI. Its `--output-format streaming-json` path intermittently emits only `thought` events and then `stopReason:"Cancelled"` with no `text`, so the adapter now consumes the reliable `--output-format json` single object on subprocess close. Bridge object `text` to `onText`, object `thought` to `onThinking`, record `sessionId`, and make non-`EndTurn` empty-text terminals diagnosable instead of silent. 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 -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). + * Cold-start ceiling: if `grok -p --output-format json` produces no stdout + * bytes 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 diagnostic, result instead of an unhandled rejection). */ -const FIRST_LINE_TIMEOUT_MS = 60_000; +const FIRST_OUTPUT_TIMEOUT_MS = 60_000; /** - * Inactivity safety net: kill the subprocess if no stdout line arrives for - * this long after the first line. Generous ceiling mirroring the Droid + * Inactivity safety net: kill the subprocess if no stdout bytes arrive for + * this long after the first chunk. Generous ceiling mirroring the Droid * adapter's rationale — the caller (Fusion's stuck-task detection / abort * signal) is the authoritative "this session is stuck" source; this is a * last-resort guard for a catastrophically hung `grok` process. @@ -55,18 +54,64 @@ 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 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}`; +function formatNoJsonDiagnostic(firstStdoutChunk: string | undefined): string { + const firstChunk = firstStdoutChunk ? compactDiagnostic(firstStdoutChunk) : ""; + if (firstChunk) { + return `Grok CLI produced stdout but no parseable JSON response for a headless prompt; first output: ${firstChunk}`; } - 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."; + return "Grok CLI produced no JSON 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 json, or exited interactive mode immediately after stdin EOF."; +} + +function formatTerminalNoTextDiagnostic(stopReason: string): string { + return `Grok CLI ended with stopReason ${stopReason} and produced no assistant text.`; } function appendMessage(session: GrokSession, role: "user" | "assistant", content: string): void { session.state.messages.push({ role, content }); } +interface ParsedPromptOutput { + text: string; + thought?: string; + stopReason?: string; + sessionId?: string; + parsed: boolean; +} + +function parsePromptOutput(stdout: string): ParsedPromptOutput { + const json = parseJsonOutput(stdout); + if (json) { + return { + text: json.text ?? "", + thought: json.thought, + stopReason: json.stopReason, + sessionId: json.sessionId, + parsed: true, + }; + } + + let text = ""; + let thought = ""; + let stopReason: string | undefined; + let sessionId: string | undefined; + let parsed = false; + for (const line of stdout.split(/\r?\n/)) { + const event = parseLine(line); + if (!event) continue; + parsed = true; + if (event.type === "text") { + text += event.data; + } else if (event.type === "thought") { + thought += event.data; + } else { + stopReason = event.stopReason; + sessionId = event.sessionId; + } + } + + return { text, thought: thought || undefined, stopReason, sessionId, parsed }; +} + export interface GrokRuntimeAdapterOptions { /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ binary?: string; @@ -125,10 +170,6 @@ export class GrokRuntimeAdapter implements AgentRuntime { try { proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal }); } catch (err) { - // Spawn threw synchronously (e.g. binary not found without shell - // resolution) — resolve, never reject, matching the CLI-adapter - // contract of always producing a well-formed result while retaining - // the concrete diagnostic for callers that surface session.state. const message = err instanceof Error ? err.message : String(err); const diagnostic = compactDiagnostic(`Grok CLI spawn failed: ${message}`); grokSession.state.errorMessage = diagnostic; @@ -139,14 +180,13 @@ export class GrokRuntimeAdapter implements AgentRuntime { } let settled = false; - let firstLineReceived = false; - let receivedNdjsonEvent = false; - let firstStdoutLine: string | undefined; - let receivedText = false; + let firstOutputReceived = false; + let firstStdoutChunk: string | undefined; let assistantText = ""; let diagnosticEmitted = false; let stderr = ""; - let firstLineTimer: NodeJS.Timeout | undefined; + let stdout = ""; + let firstOutputTimer: NodeJS.Timeout | undefined; let inactivityTimer: NodeJS.Timeout | undefined; const setErrorMessage = (message: string) => { @@ -156,23 +196,33 @@ export class GrokRuntimeAdapter implements AgentRuntime { const emitDiagnosticText = (message: string | undefined) => { const diagnostic = message?.trim(); - if (!diagnostic || receivedText || diagnosticEmitted) return; + if (!diagnostic || assistantText || diagnosticEmitted) return; diagnosticEmitted = true; grokSession.callbacks.onText?.(diagnostic); appendMessage(grokSession, "assistant", diagnostic); }; - /* - 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. + const emitParsedOutput = (parsed: ParsedPromptOutput) => { + if (parsed.thought) { + grokSession.callbacks.onThinking?.(parsed.thought); + } + if (parsed.sessionId) { + grokSession.sessionId = parsed.sessionId; + } + if (parsed.text.length > 0) { + assistantText += parsed.text; + grokSession.callbacks.onText?.(parsed.text); + return; + } + if (parsed.stopReason && parsed.stopReason !== "EndTurn") { + setErrorMessage(formatTerminalNoTextDiagnostic(parsed.stopReason)); + } + }; - 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; settled = true; - if (firstLineTimer) clearTimeout(firstLineTimer); + if (firstOutputTimer) clearTimeout(firstOutputTimer); if (inactivityTimer) clearTimeout(inactivityTimer); if (assistantText) { appendMessage(grokSession, "assistant", assistantText); @@ -193,43 +243,24 @@ export class GrokRuntimeAdapter implements AgentRuntime { }, INACTIVITY_TIMEOUT_MS); }; - firstLineTimer = setTimeout(() => { - if (firstLineReceived) return; + firstOutputTimer = setTimeout(() => { + if (firstOutputReceived) return; setErrorMessage( - `Grok CLI produced no stdout within ${FIRST_LINE_TIMEOUT_MS}ms for a headless prompt; the process was killed.`, + `Grok CLI produced no stdout within ${FIRST_OUTPUT_TIMEOUT_MS}ms for a headless prompt; the process was killed.`, ); forceKillGrokStream(proc); finish(); - }, FIRST_LINE_TIMEOUT_MS); + }, FIRST_OUTPUT_TIMEOUT_MS); - const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity, terminal: false }); - - rl.on("line", (line: string) => { - if (!firstLineReceived) { - firstLineReceived = true; - firstStdoutLine = line; - if (firstLineTimer) clearTimeout(firstLineTimer); + proc.stdout?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + if (!firstOutputReceived) { + firstOutputReceived = true; + firstStdoutChunk = text; + if (firstOutputTimer) clearTimeout(firstOutputTimer); } + stdout += text; resetInactivityTimer(); - - const event = parseLine(line); - if (!event) return; - receivedNdjsonEvent = true; - - if (event.type === "text") { - if (event.data.length > 0) { - receivedText = true; - assistantText += event.data; - } - 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; - } - // `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) => { @@ -238,31 +269,26 @@ export class GrokRuntimeAdapter implements AgentRuntime { proc.on("error", (err) => { const message = err instanceof Error ? err.message : String(err); - if (!receivedText) { + if (!assistantText) { setErrorMessage(compactDiagnostic(`Grok CLI process error: ${message}`)); } finish(); }); proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - try { - rl.close(); - } catch { - // already closed + const parsed = parsePromptOutput(stdout); + if (parsed.parsed) { + emitParsedOutput(parsed); } + const failed = typeof code === "number" ? code !== 0 : Boolean(signal); - if (!receivedText && failed) { + if (!assistantText && failed) { setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr)); - } else if (!receivedText && !receivedNdjsonEvent && typeof code === "number" && code === 0) { - setErrorMessage(formatNoNdjsonDiagnostic(firstStdoutLine)); + } else if (!assistantText && !parsed.parsed && typeof code === "number" && code === 0) { + setErrorMessage(formatNoJsonDiagnostic(firstStdoutChunk)); } finish(); }); - - rl.on("close", () => { - // Wait for the child `close` event so non-zero exits can attach stderr - // diagnostics before callers inspect the session. - }); }); } diff --git a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts index 08dabe8133..a26e481aaf 100644 --- a/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts +++ b/plugins/fusion-plugin-grok-runtime/src/stream-parser.ts @@ -1,27 +1,60 @@ -import type { GrokNdjsonEvent } from "./types.js"; +import type { GrokCliJsonResponse, GrokNdjsonEvent } from "./types.js"; /* -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-10-12:50: +FN-7796: xAI Grok Build TUI's `--output-format streaming-json` intermittently ends with `stopReason:"Cancelled"` and zero `text` events. The headless path now uses the reliable single-object `--output-format json` response, so parser callers should parse the complete stdout buffer into `{text,stopReason,sessionId,requestId,thought}` and treat invalid/partial buffers as absent output rather than throwing. */ -const KNOWN_EVENT_TYPES = new Set(["thought", "text", "end"]); /** - * 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 real xAI streaming event types). + * Parse the complete stdout buffer from + * `grok -p --output-format json` into the real xAI Grok Build TUI + * response object, or null when the output is empty, non-JSON, a JSON array, + * or an unrelated object with none of the expected response fields. */ -export function parseLine(line: string): GrokNdjsonEvent | null { - const trimmed = line.trim(); - - // Skip empty lines - if (!trimmed) { - return null; - } - - // Skip non-JSON lines (e.g. any stray debug/log output not part of the JSONL stream) - if (!trimmed.startsWith("{")) { +export function parseJsonOutput(output: string): GrokCliJsonResponse | null { + const trimmed = output.trim(); + if (!trimmed || !trimmed.startsWith("{")) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const candidate = parsed as Record; + const hasKnownField = ["text", "stopReason", "sessionId", "requestId", "thought"].some((key) => key in candidate); + if (!hasKnownField) { + return null; + } + + return { + text: typeof candidate.text === "string" ? candidate.text : undefined, + stopReason: typeof candidate.stopReason === "string" ? candidate.stopReason : undefined, + sessionId: typeof candidate.sessionId === "string" ? candidate.sessionId : undefined, + requestId: typeof candidate.requestId === "string" ? candidate.requestId : undefined, + thought: typeof candidate.thought === "string" ? candidate.thought : undefined, + }; +} + +const STREAMING_EVENT_TYPES = new Set(["thought", "text", "end"]); + +/** + * Parse a single NDJSON line from the legacy/flaky + * `--output-format streaming-json` contract. The runtime no longer relies on + * this as its primary path, but retaining this parser lets deterministic + * regressions model the live-captured cancelled-no-text stream shape and + * produce a concrete diagnostic instead of treating it as arbitrary garbage. + */ +export function parseLine(line: string): GrokNdjsonEvent | null { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("{")) { return null; } @@ -29,17 +62,15 @@ export function parseLine(line: string): GrokNdjsonEvent | null { try { parsed = JSON.parse(trimmed); } catch { - console.error("Failed to parse Grok CLI NDJSON line:", trimmed); return null; } - // Validate that the parsed result is a non-null object (not array, not primitive) if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } const candidate = parsed as { type?: unknown }; - if (typeof candidate.type !== "string" || !KNOWN_EVENT_TYPES.has(candidate.type)) { + if (typeof candidate.type !== "string" || !STREAMING_EVENT_TYPES.has(candidate.type)) { return null; } diff --git a/plugins/fusion-plugin-grok-runtime/src/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index 253244c7eb..4604255278 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -1,8 +1,17 @@ /* -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. +FNXC:GrokCli 2026-07-10-12:50: +FN-7796: xAI Grok Build TUI's `--output-format streaming-json` can emit reasoning-only events and then `stopReason:"Cancelled"` with zero assistant text. The primary headless contract is therefore the reliable single `--output-format json` object `{text,stopReason,sessionId,requestId,thought}`; streaming event types remain only for diagnostics/regressions that model the captured flaky shape. */ +export interface GrokCliJsonResponse { + text?: string; + stopReason?: string; + sessionId?: string; + requestId?: string; + thought?: string; +} + + export interface GrokThoughtEvent { type: "thought"; data: string;