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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 10:01:49 -07:00
parent dd82a601be
commit 30bd7790e7
5 changed files with 144 additions and 4 deletions

View File

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

View File

@@ -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.
<!--
FNXC:GrokCli 2026-07-10-10:02:
FN-7788 pins the zero-NDJSON invariant after the operator reported Grok CLI still returned "no message" immediately after non-zero failure diagnostics landed. A valid headless prompt must emit `step_start`; code-0/no-NDJSON closes are therefore operator-actionable runtime failures, commonly wrong-binary or interactive-EOF fallback, and adapters must not swallow them as legitimate empty replies.
-->
## Auth / readiness

View File

@@ -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<void>;
};
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),

View File

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

View File

@@ -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<void>((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 <text> --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();
});