fix(FN-7779): rebuild Grok plugin dist so headless prompts use valid CLI flags

The running dashboard loads the compiled plugin dist, which was stale and
invoked `grok --prompt <text> --format json --directory <cwd>` — flags grok
0.2.93 rejects ("unexpected argument '--prompt'"), yielding a non-zero exit,
no JSON, and an empty "No message" bubble. The source already switched to the
valid `grok -p <text> --output-format json [-m <model>] [--cwd <dir>]`
contract (FN-7790/FN-7796); this rebuilds dist to match.

Also reconcile the FN-7779 test suite: a genuinely empty response is a parsed
`{text:"",stopReason:"EndTurn"}` object, not zero stdout bytes, so the
"stays silent" test now models that shape instead of contradicting the
FN-7796 zero-stdout wrong-binary diagnostic. All 64 plugin tests pass.

Fusion-Task-Id: FN-7779
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-10 13:20:24 -07:00
parent c258fc1590
commit ee796ee991
3 changed files with 248 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Grok CLI failures now show the actual error instead of an empty chat message.
category: fix
dev: GrokRuntimeAdapter.promptWithFallback now captures stderr, bridges NDJSON `error` events, and inspects the subprocess exit code. Any run that ends with no renderable content (missing/invalid GROK_API_KEY, bad flag, non-zero exit, missing `grok` binary, cold-start/inactivity hang, or a dropped `error` event) surfaces a diagnosable reason via `onText` rather than resolving into a blank bubble. A clean content-less exit (code 0, no stderr) stays silent. Fixes the root cause behind the FN-7779 "No message" placeholder.

View File

@@ -9,7 +9,12 @@ FNXC:GrokCli 2026-07-10-12:54:
FN-7796: adapter tests are pinned to the reliable xAI Grok Build TUI headless contract (`--output-format json` single object) and the live-captured flaky `streaming-json` cancellation shape. They intentionally avoid a live binary in CI but exercise the same spawn seam and lifecycle diagnostics that previously hid wrong-contract and cancelled-no-text failures behind fake fixtures.
*/
function makeFakeProc(): { proc: GrokStreamProcess; stdout: PassThrough; stderr: 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();
@@ -359,6 +364,129 @@ describe("GrokRuntimeAdapter", () => {
expect(session.state.errorMessage).toBe("Grok CLI spawn failed: spawn ENOENT");
});
/*
FNXC:GrokCli 2026-07-10-15:10:
FN-7779 root-cause surface enumeration. The reported empty "No message" Grok
bubble was every SILENT failure collapsing into resolve-with-no-output. These
assert the invariant — a run with no renderable content surfaces a visible,
diagnosable reason via onText — across all known silent-failure surfaces:
stderr-only fatal exit, non-zero exit with no stderr, dropped NDJSON `error`
event, and process `error`. The clean content-less exit stays silent so a
legitimately empty response is not decorated with a false error.
*/
describe("FN-7779 silent-failure surfacing", () => {
it("surfaces stderr text when grok exits with no NDJSON (missing key / fatal, pre-JSON failure)", async () => {
const { proc, 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");
stderr.write("Error: GROK_API_KEY is not set\n");
proc.emit("close", 1, null);
await promise;
expect(onText).toHaveBeenCalledTimes(1);
expect(onText.mock.calls[0][0]).toContain("GROK_API_KEY is not set");
});
it("surfaces a non-zero-exit diagnostic when there is no stdout and no stderr", async () => {
const { proc } = 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");
proc.emit("close", 3, null);
await promise;
expect(onText).toHaveBeenCalledTimes(1);
expect(onText.mock.calls[0][0]).toContain("exited with code 3");
});
it("bridges a well-formed NDJSON `error` event into visible onText", 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(`${JSON.stringify({ type: "error", message: "rate limited", timestamp: 1 })}\n`);
proc.emit("close", 0, null);
await promise;
expect(onText).toHaveBeenCalledTimes(1);
expect(onText.mock.calls[0][0]).toContain("rate limited");
});
it("surfaces the process error reason instead of an empty result", async () => {
const { proc } = 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");
proc.emit("error", new Error("spawn grok ENOENT"));
await promise;
expect(onText).toHaveBeenCalledTimes(1);
expect(onText.mock.calls[0][0]).toContain("ENOENT");
});
it("surfaces a reason when the injected spawn throws synchronously", async () => {
const spawn = vi.fn().mockImplementation(() => {
throw new Error("spawn ENOENT");
});
const adapter = new GrokRuntimeAdapter({ spawn });
const onText = vi.fn();
const { session } = await adapter.createSession({ onText });
await adapter.promptWithFallback(session, "hi");
expect(onText).toHaveBeenCalledTimes(1);
expect(onText.mock.calls[0][0]).toContain("ENOENT");
});
it("stays silent on a clean, content-less response (parsed EndTurn, empty text) — no false error text", 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");
// A genuinely empty grok response is a parsed JSON object with empty
// text and stopReason EndTurn — not zero stdout bytes. It must not be
// decorated with a false error bubble.
stdout.write(JSON.stringify({ text: "", stopReason: "EndTurn", sessionId: "abc" }));
stdout.end();
proc.emit("close", 0, null);
await promise;
expect(onText).not.toHaveBeenCalled();
expect(session.state.errorMessage).toBeUndefined();
});
it("does not append a stderr diagnostic when real text content was streamed", 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("warning: deprecated flag\n");
proc.emit("close", 0, null);
await promise;
expect(onText.mock.calls.map((c) => c[0])).toEqual(["answer"]);
});
});
it("describeModel formats grok prefix", () => {
const adapter = new GrokRuntimeAdapter();
expect(adapter.describeModel({ model: "grok/pro" } as never)).toBe("grok/grok/pro");

View File

@@ -27,6 +27,45 @@ const FIRST_OUTPUT_TIMEOUT_MS = 60_000;
*/
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
/**
* FNXC:GrokCli 2026-07-10-15:10:
* FN-7779 root-cause helpers. The reported "No message" empty Grok bubble was
* not a legitimate content-empty response — it was every silent grok failure
* (missing/invalid GROK_API_KEY, bad flag, non-zero exit, missing binary)
* collapsing into a resolve-with-no-output. The frontend placeholder (FN-7779
* UI step) hid the symptom; these helpers cure the cause by turning each
* silent failure into visible, diagnosable text so the operator sees WHY grok
* returned nothing. Retargeted for FN-7796's single-JSON-object contract —
* the schema no longer carries a `tool_use`/`error` NDJSON event, so only the
* spawn/process/exit-code failure surfaces below apply.
*/
function emitFailureText(session: GrokSession, text: string): void {
session.callbacks.onText?.(text);
}
function describeSpawnFailure(error: unknown): string {
const reason = error instanceof Error ? error.message : String(error ?? "unknown error");
return `Grok CLI failed to start: ${reason}. Ensure the \`grok\` binary is installed and on PATH, or set GROK_API_KEY to use the direct xAI endpoint.`;
}
/**
* Build the operator-facing message for a run that finished with NO renderable
* content. Prefer the captured stderr (the channel for fatal, pre-JSON
* failures); otherwise fall back to a non-zero-exit diagnostic. Returns
* undefined for a genuinely clean, content-less exit (code 0, no stderr) so a
* legitimately empty response is not decorated with a false error.
*/
function describeSilentFailure(stderr: string, exitCode: number | null | undefined): string | undefined {
const trimmed = stderr.trim();
if (trimmed) {
return `Grok CLI returned no content. ${trimmed}`;
}
if (typeof exitCode === "number" && exitCode !== 0) {
return `Grok CLI exited with code ${exitCode} and produced no output. Check that GROK_API_KEY (or the \`grok\` login) is configured and the selected model is valid.`;
}
return undefined;
}
function normalizeGrokCliModel(model: string | undefined): string | undefined {
const normalized = model?.trim();
if (!normalized) return undefined;
@@ -169,12 +208,19 @@ export class GrokRuntimeAdapter implements AgentRuntime {
let proc: GrokStreamProcess;
try {
proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
} catch (spawnError) {
// 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 result while retaining
// the concrete diagnostic for callers that surface session.state, AND
// (FN-7779 root-cause) surfacing the reason as visible text so the
// user sees a diagnosable failure instead of an empty bubble.
const message = spawnError instanceof Error ? spawnError.message : String(spawnError);
const diagnostic = compactDiagnostic(`Grok CLI spawn failed: ${message}`);
grokSession.state.errorMessage = diagnostic;
grokSession.callbacks.onText?.(diagnostic);
appendMessage(grokSession, "assistant", diagnostic);
const failureMessage = describeSpawnFailure(spawnError);
emitFailureText(grokSession, failureMessage);
appendMessage(grokSession, "assistant", failureMessage);
resolve();
return;
}
@@ -188,6 +234,13 @@ export class GrokRuntimeAdapter implements AgentRuntime {
let stdout = "";
let firstOutputTimer: NodeJS.Timeout | undefined;
let inactivityTimer: NodeJS.Timeout | undefined;
// FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — track whether any
// renderable content (real assistant text) or a fallback diagnostic has
// already been surfaced via onText, so a run that finished with NO
// renderable content gets exactly one visible reason instead of an
// empty "No message" assistant bubble (and never a duplicate
// diagnostic on top of real content).
let contentEmitted = false;
const setErrorMessage = (message: string) => {
if (message.trim().length === 0) return;
@@ -196,8 +249,9 @@ export class GrokRuntimeAdapter implements AgentRuntime {
const emitDiagnosticText = (message: string | undefined) => {
const diagnostic = message?.trim();
if (!diagnostic || assistantText || diagnosticEmitted) return;
if (!diagnostic || assistantText || diagnosticEmitted || contentEmitted) return;
diagnosticEmitted = true;
contentEmitted = true;
grokSession.callbacks.onText?.(diagnostic);
appendMessage(grokSession, "assistant", diagnostic);
};
@@ -211,6 +265,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
}
if (parsed.text.length > 0) {
assistantText += parsed.text;
contentEmitted = true;
grokSession.callbacks.onText?.(parsed.text);
return;
}
@@ -219,7 +274,24 @@ export class GrokRuntimeAdapter implements AgentRuntime {
}
};
const finish = () => {
/*
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 too early 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/parse 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-12:52:
FN-7796 replaces the streaming-json/NDJSON contract with a single JSON object parsed once on subprocess close (`parsePromptOutput`/`emitParsedOutput`), because streaming-json intermittently emitted only `thought` + `stopReason:"Cancelled"` with no `text`. `stdout` is accumulated in full across `data` chunks rather than parsed line-by-line as it arrives.
FNXC:GrokCli 2026-07-10-15:10:
FN-7779 root-cause — the above only covered the zero-output shape. A run
that DID exit non-zero (or produced fatal stderr) with no renderable
content still resolved silently once `session.state.errorMessage` had
already been consumed by `emitDiagnosticText` for a different reason (or
not set at all). If nothing was rendered AND no diagnostic has been
emitted yet, fall back to `describeSilentFailure` (stderr-first, then a
non-zero-exit reason) so every silent failure surface gets a visible,
diagnosable `onText` — never a bare empty resolve.
*/
const finish = (exitCode?: number | null) => {
if (settled) return;
settled = true;
if (firstOutputTimer) clearTimeout(firstOutputTimer);
@@ -227,7 +299,21 @@ export class GrokRuntimeAdapter implements AgentRuntime {
if (assistantText) {
appendMessage(grokSession, "assistant", assistantText);
} else {
emitDiagnosticText(grokSession.state.errorMessage);
// FN-7779's stderr/exit-code diagnostic takes priority when the run
// actually failed (non-empty stderr or non-zero exit): it names the
// concrete cause. Only fall back to the FN-7796 parse-shape
// diagnostic (session.state.errorMessage, e.g. "produced no JSON
// output") for the remaining case that describeSilentFailure can't
// describe — a code-0 exit with no stderr that still produced no
// parseable output.
const failure = describeSilentFailure(stderr, exitCode);
if (failure && !contentEmitted) {
contentEmitted = true;
emitFailureText(grokSession, failure);
appendMessage(grokSession, "assistant", failure);
} else {
emitDiagnosticText(grokSession.state.errorMessage);
}
}
resolve();
};
@@ -264,14 +350,29 @@ export class GrokRuntimeAdapter implements AgentRuntime {
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
// FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — xAI's Grok
// Build TUI writes fatal, pre-JSON failures (missing API key,
// invalid flag, auth error) to stderr with no JSON on stdout.
// Reading stdout alone would lose the entire failure reason, so
// stderr is captured for both the FN-7796 close diagnostic and the
// FN-7779 silent-failure fallback below. Capped to avoid unbounded
// growth on a pathologically chatty process.
if (stderr.length < 8192) stderr += chunk.toString();
});
proc.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err);
proc.on("error", (procError) => {
// FNXC:GrokCli 2026-07-10-15:10: FN-7779 root-cause — spawn/runtime
// process error (e.g. ENOENT for a missing `grok` binary) previously
// resolved into an empty bubble; surface the reason both on
// session.state (unchanged historical format) and as visible text
// via finish()'s silent-failure fallback.
const message = procError instanceof Error ? procError.message : String(procError);
if (!assistantText) {
setErrorMessage(compactDiagnostic(`Grok CLI process error: ${message}`));
}
if (!contentEmitted && !stderr) {
stderr = describeSpawnFailure(procError);
}
finish();
});
@@ -287,7 +388,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
} else if (!assistantText && !parsed.parsed && typeof code === "number" && code === 0) {
setErrorMessage(formatNoJsonDiagnostic(firstStdoutChunk));
}
finish();
finish(code);
});
});
}