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:
7
.changeset/fn-7788-grok-cli-immediate-no-message.md
Normal file
7
.changeset/fn-7788-grok-cli-immediate-no-message.md
Normal 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.
|
||||||
@@ -108,6 +108,18 @@ Notes:
|
|||||||
line to stderr and calls `process.exit(1)` before any NDJSON is emitted.
|
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
|
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 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
|
## Auth / readiness
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,36 @@ describe("Grok CLI runtime routing (FN-7725)", () => {
|
|||||||
expect(onText.mock.calls.map((c) => c[0])).toEqual(["hi ", "there"]);
|
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 () => {
|
it("falls back to the default pi runtime when the Grok plugin runtime is not registered", async () => {
|
||||||
const pluginRunner = createMockPluginRunner({
|
const pluginRunner = createMockPluginRunner({
|
||||||
getRuntimeById: vi.fn().mockReturnValue(undefined),
|
getRuntimeById: vi.fn().mockReturnValue(undefined),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe("GrokRuntimeAdapter", () => {
|
|||||||
|
|
||||||
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({}));
|
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({}));
|
||||||
expect(onText.mock.calls.map((c) => c[0])).toEqual(["hel", "lo!"]);
|
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 () => {
|
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.");
|
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 { proc, stdout } = makeFakeProc();
|
||||||
const spawn = vi.fn().mockReturnValue(proc);
|
const spawn = vi.fn().mockReturnValue(proc);
|
||||||
const adapter = new GrokRuntimeAdapter({ spawn });
|
const adapter = new GrokRuntimeAdapter({ spawn });
|
||||||
const { session } = await adapter.createSession({});
|
const { session } = await adapter.createSession({});
|
||||||
|
|
||||||
const promise = adapter.promptWithFallback(session, "hi");
|
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);
|
proc.emit("close", 0, null);
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,18 @@ function formatErrorEventDiagnostic(event: GrokErrorEvent): string {
|
|||||||
return detail ? `Grok CLI error: ${detail}` : "Grok CLI emitted an error event without a message.";
|
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 {
|
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;
|
||||||
@@ -157,6 +169,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
const grokSession = session as GrokSession;
|
const grokSession = session as GrokSession;
|
||||||
const cwd = options?.cwd;
|
const cwd = options?.cwd;
|
||||||
const signal = options?.signal;
|
const signal = options?.signal;
|
||||||
|
appendMessage(grokSession, "user", prompt);
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
let proc: GrokStreamProcess;
|
let proc: GrokStreamProcess;
|
||||||
@@ -168,14 +181,21 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
// contract of always producing a well-formed result while retaining
|
// contract of always producing a well-formed result while retaining
|
||||||
// the concrete diagnostic for callers that surface session.state.
|
// the concrete diagnostic for callers that surface session.state.
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
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();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let firstLineReceived = false;
|
let firstLineReceived = false;
|
||||||
|
let receivedNdjsonEvent = false;
|
||||||
|
let firstStdoutLine: string | undefined;
|
||||||
let receivedText = false;
|
let receivedText = false;
|
||||||
|
let assistantText = "";
|
||||||
|
let diagnosticEmitted = false;
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let firstLineTimer: NodeJS.Timeout | undefined;
|
let firstLineTimer: NodeJS.Timeout | undefined;
|
||||||
let inactivityTimer: NodeJS.Timeout | undefined;
|
let inactivityTimer: NodeJS.Timeout | undefined;
|
||||||
@@ -185,21 +205,40 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
grokSession.state.errorMessage = message;
|
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:
|
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.
|
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 = () => {
|
const finish = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (firstLineTimer) clearTimeout(firstLineTimer);
|
if (firstLineTimer) clearTimeout(firstLineTimer);
|
||||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||||
|
if (assistantText) {
|
||||||
|
appendMessage(grokSession, "assistant", assistantText);
|
||||||
|
} else {
|
||||||
|
emitDiagnosticText(grokSession.state.errorMessage);
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetInactivityTimer = () => {
|
const resetInactivityTimer = () => {
|
||||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||||
inactivityTimer = setTimeout(() => {
|
inactivityTimer = setTimeout(() => {
|
||||||
|
setErrorMessage(
|
||||||
|
`Grok CLI stopped producing stdout for ${INACTIVITY_TIMEOUT_MS}ms during a headless prompt; the process was killed.`,
|
||||||
|
);
|
||||||
forceKillGrokStream(proc);
|
forceKillGrokStream(proc);
|
||||||
finish();
|
finish();
|
||||||
}, INACTIVITY_TIMEOUT_MS);
|
}, INACTIVITY_TIMEOUT_MS);
|
||||||
@@ -207,6 +246,9 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
|
|
||||||
firstLineTimer = setTimeout(() => {
|
firstLineTimer = setTimeout(() => {
|
||||||
if (firstLineReceived) return;
|
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);
|
forceKillGrokStream(proc);
|
||||||
finish();
|
finish();
|
||||||
}, FIRST_LINE_TIMEOUT_MS);
|
}, FIRST_LINE_TIMEOUT_MS);
|
||||||
@@ -216,15 +258,20 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
rl.on("line", (line: string) => {
|
rl.on("line", (line: string) => {
|
||||||
if (!firstLineReceived) {
|
if (!firstLineReceived) {
|
||||||
firstLineReceived = true;
|
firstLineReceived = true;
|
||||||
|
firstStdoutLine = line;
|
||||||
if (firstLineTimer) clearTimeout(firstLineTimer);
|
if (firstLineTimer) clearTimeout(firstLineTimer);
|
||||||
}
|
}
|
||||||
resetInactivityTimer();
|
resetInactivityTimer();
|
||||||
|
|
||||||
const event = parseLine(line);
|
const event = parseLine(line);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
|
receivedNdjsonEvent = true;
|
||||||
|
|
||||||
if (event.type === "text") {
|
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);
|
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
|
||||||
@@ -269,6 +316,8 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
const failed = typeof code === "number" ? code !== 0 : Boolean(signal);
|
const failed = typeof code === "number" ? code !== 0 : Boolean(signal);
|
||||||
if (!receivedText && failed) {
|
if (!receivedText && failed) {
|
||||||
setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr));
|
setErrorMessage(formatCloseDiagnostic(typeof code === "number" ? code : null, signal, stderr));
|
||||||
|
} else if (!receivedText && !receivedNdjsonEvent && typeof code === "number" && code === 0) {
|
||||||
|
setErrorMessage(formatNoNdjsonDiagnostic(firstStdoutLine));
|
||||||
}
|
}
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user