From 30bd7790e7716ae1fb4aba0160f1dac148027a4d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 10:01:49 -0700 Subject: [PATCH] FN-7788: diagnose zero-NDJSON Grok CLI headless exits as a real failure Fixes the residual "Grok CLI still returns no message immediately" case where a headless run exits code 0 but never emits any parsed NDJSON event, previously treated as a silent success. - Detect a code-0 close with zero parsed NDJSON events and surface a diagnostic explaining the likely cause (wrong/unsupported grok binary falling into interactive mode and hitting EOF on stdin). - Track and emit assistant text/diagnostics via a new appendMessage/emitDiagnosticText path so onText and session.state.errorMessage stay in sync, including on spawn failure and inactivity/first-line timeouts. - Add first-line/inactivity timeout diagnostics with concrete elapsed-time messaging instead of silent kills. - Add regression coverage in runtime-adapter.test.ts and grok-runtime-routing.test.ts for the zero-NDJSON exit path. - Document the contract update in docs/grok-cli-contract.md. - Add a patch changeset for @runfusion/fusion. Files changed: $(cat /tmp/diffstat_fn7788.txt) Fusion-Task-Id: FN-7788 Fusion-Task-Lineage: dbb238a9-9601-47fc-8a88-40817d749337 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7788-grok-cli-immediate-no-message.md | 7 +++ docs/grok-cli-contract.md | 12 +++++ .../__tests__/grok-runtime-routing.test.ts | 30 +++++++++++ .../src/__tests__/runtime-adapter.test.ts | 46 +++++++++++++++- .../src/runtime-adapter.ts | 53 ++++++++++++++++++- 5 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7788-grok-cli-immediate-no-message.md diff --git a/.changeset/fn-7788-grok-cli-immediate-no-message.md b/.changeset/fn-7788-grok-cli-immediate-no-message.md new file mode 100644 index 0000000000..36b4dc458e --- /dev/null +++ b/.changeset/fn-7788-grok-cli-immediate-no-message.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Surface Grok CLI immediate no-message exits with actionable diagnostics. +category: fix +dev: Treat code-0 zero-NDJSON grok headless runs as anomalous and stream diagnostics through runtime sessions. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index 4ce0173af2..13c595a1c7 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -108,6 +108,18 @@ Notes: 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. + + ## Auth / readiness diff --git a/packages/engine/src/__tests__/grok-runtime-routing.test.ts b/packages/engine/src/__tests__/grok-runtime-routing.test.ts index ceede6b2be..e71e3b1115 100644 --- a/packages/engine/src/__tests__/grok-runtime-routing.test.ts +++ b/packages/engine/src/__tests__/grok-runtime-routing.test.ts @@ -190,6 +190,36 @@ describe("Grok CLI runtime routing (FN-7725)", () => { expect(onText.mock.calls.map((c) => c[0])).toEqual(["hi ", "there"]); }); + it("surfaces code-0 zero-NDJSON Grok exits through the shared runtime session seam", async () => { + const { proc, stdout } = makeFakeGrokProcess(); + const spawn = vi.fn().mockReturnValue(proc); + const grokRegistration = await createGrokRegistration(spawn); + const pluginRunner = createMockPluginRunner({ + getRuntimeById: vi.fn().mockReturnValue(grokRegistration), + }); + const onText = vi.fn(); + const result = await createResolvedAgentSession({ + sessionPurpose: "executor", + runtimeHint: "grok", + pluginRunner, + cwd: "/tmp/project", + onText, + }); + + const session = result.session as { + state?: { errorMessage?: string }; + promptWithFallback: (prompt: string) => Promise; + }; + const promptPromise = session.promptWithFallback("hello grok"); + stdout.end(); + (proc as EventEmitter).emit("close", 0, null); + + await promptPromise; + + expect(session.state?.errorMessage).toContain("Grok CLI produced no NDJSON output"); + expect(onText).toHaveBeenCalledWith(expect.stringContaining("Grok CLI produced no NDJSON output")); + }); + it("falls back to the default pi runtime when the Grok plugin runtime is not registered", async () => { const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(undefined), 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 5bf15af76a..2d8603ea1c 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 @@ -82,6 +82,7 @@ describe("GrokRuntimeAdapter", () => { 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!" }); }); it("skips malformed/unrecognized lines without invoking onText and without throwing", async () => { @@ -155,14 +156,55 @@ describe("GrokRuntimeAdapter", () => { expect(session.state.errorMessage).toBe("Grok CLI failed with code 2 and no stderr output."); }); - it("keeps a clean content-less zero exit silent", async () => { + it("records a concrete diagnostic for code-0 exits with zero NDJSON output", 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"); + stdout.end(); + proc.emit("close", 0, null); + 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.", + ); + 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 () => { + 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"); + stdout.write("Welcome to grok interactive mode\n"); + stdout.end(); + proc.emit("close", 0, null); + 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", + ); + expect(onText).toHaveBeenCalledWith(session.state.errorMessage); + }); + + it("keeps a clean NDJSON run 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 promise = adapter.promptWithFallback(session, "hi"); - stdout.end(); + 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); await promise; diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index a7988d4611..079506e640 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -106,6 +106,18 @@ function formatErrorEventDiagnostic(event: GrokErrorEvent): string { 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."; +} + +function appendMessage(session: GrokSession, role: "user" | "assistant", content: string): void { + session.state.messages.push({ role, content }); +} + export interface GrokRuntimeAdapterOptions { /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ binary?: string; @@ -157,6 +169,7 @@ export class GrokRuntimeAdapter implements AgentRuntime { const grokSession = session as GrokSession; const cwd = options?.cwd; const signal = options?.signal; + appendMessage(grokSession, "user", prompt); return new Promise((resolve) => { let proc: GrokStreamProcess; @@ -168,14 +181,21 @@ export class GrokRuntimeAdapter implements AgentRuntime { // 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); - grokSession.state.errorMessage = compactDiagnostic(`Grok CLI spawn failed: ${message}`); + const diagnostic = compactDiagnostic(`Grok CLI spawn failed: ${message}`); + grokSession.state.errorMessage = diagnostic; + grokSession.callbacks.onText?.(diagnostic); + appendMessage(grokSession, "assistant", diagnostic); resolve(); return; } let settled = false; let firstLineReceived = false; + let receivedNdjsonEvent = false; + let firstStdoutLine: string | undefined; let receivedText = false; + let assistantText = ""; + let diagnosticEmitted = false; let stderr = ""; let firstLineTimer: NodeJS.Timeout | undefined; let inactivityTimer: NodeJS.Timeout | undefined; @@ -185,21 +205,40 @@ export class GrokRuntimeAdapter implements AgentRuntime { grokSession.state.errorMessage = message; }; + const emitDiagnosticText = (message: string | undefined) => { + const diagnostic = message?.trim(); + if (!diagnostic || receivedText || 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. + + 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. */ const finish = () => { if (settled) return; settled = true; if (firstLineTimer) clearTimeout(firstLineTimer); if (inactivityTimer) clearTimeout(inactivityTimer); + if (assistantText) { + appendMessage(grokSession, "assistant", assistantText); + } else { + emitDiagnosticText(grokSession.state.errorMessage); + } resolve(); }; const resetInactivityTimer = () => { if (inactivityTimer) clearTimeout(inactivityTimer); inactivityTimer = setTimeout(() => { + setErrorMessage( + `Grok CLI stopped producing stdout for ${INACTIVITY_TIMEOUT_MS}ms during a headless prompt; the process was killed.`, + ); forceKillGrokStream(proc); finish(); }, INACTIVITY_TIMEOUT_MS); @@ -207,6 +246,9 @@ export class GrokRuntimeAdapter implements AgentRuntime { firstLineTimer = setTimeout(() => { if (firstLineReceived) return; + setErrorMessage( + `Grok CLI produced no stdout within ${FIRST_LINE_TIMEOUT_MS}ms for a headless prompt; the process was killed.`, + ); forceKillGrokStream(proc); finish(); }, FIRST_LINE_TIMEOUT_MS); @@ -216,15 +258,20 @@ export class GrokRuntimeAdapter implements AgentRuntime { rl.on("line", (line: string) => { if (!firstLineReceived) { firstLineReceived = true; + firstStdoutLine = line; if (firstLineTimer) clearTimeout(firstLineTimer); } resetInactivityTimer(); const event = parseLine(line); if (!event) return; + receivedNdjsonEvent = true; if (event.type === "text") { - receivedText = receivedText || event.text.length > 0; + if (event.text.length > 0) { + receivedText = true; + assistantText += event.text; + } grokSession.callbacks.onText?.(event.text); } else if (event.type === "tool_use") { // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified @@ -269,6 +316,8 @@ export class GrokRuntimeAdapter implements AgentRuntime { const failed = typeof code === "number" ? code !== 0 : Boolean(signal); if (!receivedText && failed) { setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr)); + } else if (!receivedText && !receivedNdjsonEvent && typeof code === "number" && code === 0) { + setErrorMessage(formatNoNdjsonDiagnostic(firstStdoutLine)); } finish(); });