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".
*/
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 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", () => {

View File

@@ -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<AgentSessionResult> {
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.
});
});
}

View File

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