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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:11:43 -07:00
parent 03073afa85
commit 2e97395cf3
4 changed files with 161 additions and 20 deletions

View File

@@ -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.

View File

@@ -15,12 +15,13 @@ resolves on close/error. Uses fake timers for the lifecycle timeout paths
per AGENTS.md "Do Not Add Slow Tests". per AGENTS.md "Do Not Add Slow Tests".
*/ */
function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; kill: ReturnType<typeof vi.fn> } { function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: PassThrough; kill: ReturnType<typeof vi.fn> } {
const stdout = new PassThrough(); const stdout = new PassThrough();
const stderr = new PassThrough();
const emitter = new EventEmitter(); const emitter = new EventEmitter();
const kill = vi.fn(); const kill = vi.fn();
const proc = Object.assign(emitter, { stdout, kill }) as unknown as GrokStreamProcess; const proc = Object.assign(emitter, { stdout, stderr, kill }) as unknown as GrokStreamProcess;
return { proc, stdout, kill }; return { proc, stdout, stderr, kill };
} }
describe("GrokRuntimeAdapter", () => { describe("GrokRuntimeAdapter", () => {
@@ -101,7 +102,7 @@ describe("GrokRuntimeAdapter", () => {
expect(onText).not.toHaveBeenCalled(); 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 { proc } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc); const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn }); const adapter = new GrokRuntimeAdapter({ spawn });
@@ -111,6 +112,92 @@ describe("GrokRuntimeAdapter", () => {
proc.emit("error", new Error("ENOENT")); proc.emit("error", new Error("ENOENT"));
await expect(promise).resolves.toBeUndefined(); 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. // 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(() => { const spawn = vi.fn().mockImplementation(() => {
throw new Error("spawn ENOENT"); throw new Error("spawn ENOENT");
}); });
@@ -278,6 +365,7 @@ describe("GrokRuntimeAdapter", () => {
const { session } = await adapter.createSession({}); const { session } = await adapter.createSession({});
await expect(adapter.promptWithFallback(session, "hi")).resolves.toBeUndefined(); await expect(adapter.promptWithFallback(session, "hi")).resolves.toBeUndefined();
expect(session.state.errorMessage).toBe("Grok CLI spawn failed: spawn ENOENT");
}); });
it("describeModel formats grok prefix", () => { it("describeModel formats grok prefix", () => {

View File

@@ -1,7 +1,7 @@
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { forceKillGrokStream, spawnGrokStream, type GrokStreamProcess, type SpawnGrokStreamOptions } from "./cli-stream.js"; import { forceKillGrokStream, spawnGrokStream, type GrokStreamProcess, type SpawnGrokStreamOptions } from "./cli-stream.js";
import { parseLine } from "./stream-parser.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: FNXC:GrokCli 2026-07-09-00:00:
@@ -91,6 +91,21 @@ function modelForCli(model: string | undefined): string | undefined {
return normalized && normalized !== "default" ? normalized : 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 { export interface GrokRuntimeAdapterOptions {
/** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */ /** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */
binary?: string; binary?: string;
@@ -120,10 +135,12 @@ export class GrokRuntimeAdapter implements AgentRuntime {
} = {}, } = {},
): Promise<AgentSessionResult> { ): Promise<AgentSessionResult> {
const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default"; const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default";
const messages: unknown[] = [];
const session: GrokSession = { const session: GrokSession = {
model, model,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
messages: [], messages,
state: { messages },
sessionId: undefined, sessionId: undefined,
lastModelDescription: `grok/${model}`, lastModelDescription: `grok/${model}`,
callbacks: { callbacks: {
@@ -145,19 +162,33 @@ export class GrokRuntimeAdapter implements AgentRuntime {
let proc: GrokStreamProcess; let proc: GrokStreamProcess;
try { try {
proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal }); 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 // Spawn threw synchronously (e.g. binary not found without shell
// resolution) — resolve, never reject, matching the CLI-adapter // 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(); resolve();
return; return;
} }
let settled = false; let settled = false;
let firstLineReceived = false; let firstLineReceived = false;
let receivedText = false;
let stderr = "";
let firstLineTimer: NodeJS.Timeout | undefined; let firstLineTimer: NodeJS.Timeout | undefined;
let inactivityTimer: 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 = () => { const finish = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
@@ -193,6 +224,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
if (!event) return; if (!event) return;
if (event.type === "text") { if (event.type === "text") {
receivedText = receivedText || event.text.length > 0;
grokSession.callbacks.onText?.(event.text); grokSession.callbacks.onText?.(event.text);
} else if (event.type === "tool_use") { } else if (event.type === "tool_use") {
// FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified // 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); grokSession.callbacks.onToolStart?.(toolName, args);
const isError = event.toolResult?.success === false; const isError = event.toolResult?.success === false;
grokSession.callbacks.onToolEnd?.(toolName, isError, event.toolResult); 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 // step_start / step_finish: step_finish is a per-step boundary (not
// boundary (not run-terminal, per docs/grok-cli-contract.md — a run // run-terminal, per docs/grok-cli-contract.md — a run can have
// can have multiple step_start/step_finish pairs for multi-round // multiple step_start/step_finish pairs for multi-round tool use), so
// tool use), so it is intentionally NOT bridged into a callback or // it is intentionally NOT bridged into a callback or treated as the
// treated as the finalize signal; only subprocess close/error // finalize signal; only subprocess close/error finalizes (see finish()
// finalizes (see finish() below). `error` events carry no dedicated // below).
// callback in this scoped adapter (mirrors FN-7722: they can appear
// inline without ending the process, per the verified contract).
}); });
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(); finish();
}); });
proc.on("close", () => { proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
try { try {
rl.close(); rl.close();
} catch { } catch {
// already closed // already closed
} }
const failed = typeof code === "number" ? code !== 0 : Boolean(signal);
if (!receivedText && failed) {
setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr));
}
finish(); finish();
}); });
rl.on("close", () => { rl.on("close", () => {
finish(); // Wait for the child `close` event so non-zero exits can attach stderr
// diagnostics before callers inspect the session.
}); });
}); });
} }

View File

@@ -107,6 +107,7 @@ export interface GrokSession {
model: string; model: string;
systemPrompt?: string; systemPrompt?: string;
messages: unknown[]; messages: unknown[];
state: { errorMessage?: string; messages: unknown[] };
sessionId?: string; sessionId?: string;
lastModelDescription: string; lastModelDescription: string;
callbacks: GrokCallbacks; callbacks: GrokCallbacks;