From 2e97395cf3242d240d7c2563c1d5386cee11bc0d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 08:11:43 -0700 Subject: [PATCH] FN-7782: surface Grok CLI failures instead of silent empty replies Root-causes the Grok CLI empty-response bug: readline close no longer finalizes the session before the subprocess close event can attach exit code/stderr diagnostics, so failures were silently swallowed as empty assistant messages. - Wait for subprocess close/error (not readline close) to finalize the Grok CLI session, so non-zero exits can attach stderr before callers inspect the result - Add GrokSession.state.errorMessage to carry concrete diagnostics (spawn failure, process error, non-zero exit + stderr, or NDJSON error event) through the resolve-never-reject runtime contract - Track whether any text was received so error diagnostics are only recorded when the run actually produced nothing - Add a changeset documenting the fix for @runfusion/fusion - Extend runtime-adapter tests to cover spawn failure, process error, non-zero exit with/without stderr, and NDJSON error-event diagnostics Files changed: .changeset/fn-7782-grok-cli-no-response.md | 7 ++ .../src/__tests__/runtime-adapter.test.ts | 98 ++++++++++++++++++++-- .../src/runtime-adapter.ts | 75 +++++++++++++---- plugins/fusion-plugin-grok-runtime/src/types.ts | 1 + 4 files changed, 161 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7782 Fusion-Task-Lineage: c2907a70-0556-488f-bda3-132657b64071 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7782-grok-cli-no-response.md | 7 ++ .../src/__tests__/runtime-adapter.test.ts | 98 ++++++++++++++++++- .../src/runtime-adapter.ts | 75 +++++++++++--- .../fusion-plugin-grok-runtime/src/types.ts | 1 + 4 files changed, 161 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-7782-grok-cli-no-response.md diff --git a/.changeset/fn-7782-grok-cli-no-response.md b/.changeset/fn-7782-grok-cli-no-response.md new file mode 100644 index 0000000000..fedb60f8cf --- /dev/null +++ b/.changeset/fn-7782-grok-cli-no-response.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Surface Grok CLI runtime failures instead of empty chat replies. +category: fix +dev: Keeps Grok CLI prompt resolution non-throwing while waiting for child close to capture stderr diagnostics. 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 31567d972b..5bf15af76a 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 @@ -15,12 +15,13 @@ resolves on close/error. Uses fake timers for the lifecycle timeout paths per AGENTS.md "Do Not Add Slow Tests". */ -function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; kill: ReturnType } { +function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: PassThrough; kill: ReturnType } { const stdout = new PassThrough(); + const stderr = new PassThrough(); const emitter = new EventEmitter(); const kill = vi.fn(); - const proc = Object.assign(emitter, { stdout, kill }) as unknown as GrokStreamProcess; - return { proc, stdout, kill }; + const proc = Object.assign(emitter, { stdout, stderr, kill }) as unknown as GrokStreamProcess; + return { proc, stdout, stderr, kill }; } describe("GrokRuntimeAdapter", () => { @@ -101,7 +102,7 @@ describe("GrokRuntimeAdapter", () => { expect(onText).not.toHaveBeenCalled(); }); - it("resolves (never rejects) when the subprocess emits an error", async () => { + it("resolves (never rejects) when the subprocess emits an error and records the diagnostic", async () => { const { proc } = makeFakeProc(); const spawn = vi.fn().mockReturnValue(proc); const adapter = new GrokRuntimeAdapter({ spawn }); @@ -111,6 +112,92 @@ describe("GrokRuntimeAdapter", () => { proc.emit("error", new Error("ENOENT")); await expect(promise).resolves.toBeUndefined(); + expect(session.state.errorMessage).toBe("Grok CLI process error: ENOENT"); + }); + + it("waits for child close after stdout ends so fatal stderr becomes the chat diagnostic", async () => { + const { proc, stdout, stderr } = makeFakeProc(); + const spawn = vi.fn().mockReturnValue(proc); + const adapter = new GrokRuntimeAdapter({ spawn }); + const { session } = await adapter.createSession({}); + + const promise = adapter.promptWithFallback(session, "hi"); + let resolved = false; + void promise.then(() => { + resolved = true; + }); + + stdout.end(); + await Promise.resolve(); + 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); + await promise; + + expect(session.state.errorMessage).toBe( + "Grok CLI failed (code 1): Error: API key required. Set GROK_API_KEY env var", + ); + }); + + it("records a concrete diagnostic for non-zero exits with no stderr", 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(); + proc.emit("close", 2, null); + await promise; + + 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 () => { + 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(); + proc.emit("close", 0, null); + await promise; + + 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"); + }); + + it("does not turn a successful text response into an error when stderr is noisy", async () => { + const { proc, stdout, stderr } = 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(`${JSON.stringify({ type: "text", stepNumber: 1, text: "answer", timestamp: 1 })}\n`); + stderr.write("debug noise\n"); + proc.emit("close", 1, null); + await promise; + + expect(onText).toHaveBeenCalledWith("answer"); + expect(session.state.errorMessage).toBeUndefined(); }); // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — tool_use bridging coverage. @@ -270,7 +357,7 @@ describe("GrokRuntimeAdapter", () => { }); }); - it("resolves without throwing if the injected spawn function throws synchronously", async () => { + it("resolves without throwing if the injected spawn function throws synchronously and records the diagnostic", async () => { const spawn = vi.fn().mockImplementation(() => { throw new Error("spawn ENOENT"); }); @@ -278,6 +365,7 @@ describe("GrokRuntimeAdapter", () => { const { session } = await adapter.createSession({}); await expect(adapter.promptWithFallback(session, "hi")).resolves.toBeUndefined(); + expect(session.state.errorMessage).toBe("Grok CLI spawn failed: spawn ENOENT"); }); it("describeModel formats grok prefix", () => { diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index 5a35a7a873..a7988d4611 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -1,7 +1,7 @@ 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, GrokSession } from "./types.js"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSession, AgentSessionResult, GrokErrorEvent, GrokSession } from "./types.js"; /* FNXC:GrokCli 2026-07-09-00:00: @@ -91,6 +91,21 @@ function modelForCli(model: string | undefined): string | undefined { return normalized && normalized !== "default" ? normalized : undefined; } +function compactDiagnostic(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function formatCloseDiagnostic(code: number | null, signal: NodeJS.Signals | null, stderr: string): string { + const detail = compactDiagnostic(stderr); + const exitDetail = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`; + 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."; +} + export interface GrokRuntimeAdapterOptions { /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ binary?: string; @@ -120,10 +135,12 @@ export class GrokRuntimeAdapter implements AgentRuntime { } = {}, ): Promise { const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default"; + const messages: unknown[] = []; const session: GrokSession = { model, systemPrompt: options.systemPrompt, - messages: [], + messages, + state: { messages }, sessionId: undefined, lastModelDescription: `grok/${model}`, callbacks: { @@ -145,19 +162,33 @@ export class GrokRuntimeAdapter implements AgentRuntime { let proc: GrokStreamProcess; try { proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal }); - } catch { + } 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 (if empty) result. + // 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}`); resolve(); return; } let settled = false; let firstLineReceived = false; + let receivedText = false; + let stderr = ""; let firstLineTimer: NodeJS.Timeout | undefined; let inactivityTimer: NodeJS.Timeout | undefined; + const setErrorMessage = (message: string) => { + if (message.trim().length === 0) return; + grokSession.state.errorMessage = message; + }; + + /* + 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 finish = () => { if (settled) return; settled = true; @@ -193,6 +224,7 @@ export class GrokRuntimeAdapter implements AgentRuntime { if (!event) return; if (event.type === "text") { + receivedText = receivedText || event.text.length > 0; grokSession.callbacks.onText?.(event.text); } else if (event.type === "tool_use") { // FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified @@ -205,32 +237,45 @@ export class GrokRuntimeAdapter implements AgentRuntime { 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)); } - // step_start / step_finish / error: 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). `error` events carry no dedicated - // callback in this scoped adapter (mirrors FN-7722: they can appear - // inline without ending the process, per the verified contract). + // 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). }); - proc.on("error", () => { + proc.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + proc.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + if (!receivedText) { + setErrorMessage(compactDiagnostic(`Grok CLI process error: ${message}`)); + } finish(); }); - proc.on("close", () => { + proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => { try { rl.close(); } catch { // already closed } + const failed = typeof code === "number" ? code !== 0 : Boolean(signal); + if (!receivedText && failed) { + setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr)); + } finish(); }); rl.on("close", () => { - finish(); + // 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/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index 9ec3318119..b792917df7 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -107,6 +107,7 @@ export interface GrokSession { model: string; systemPrompt?: string; messages: unknown[]; + state: { errorMessage?: string; messages: unknown[] }; sessionId?: string; lastModelDescription: string; callbacks: GrokCallbacks;