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:
7
.changeset/fn-7782-grok-cli-no-response.md
Normal file
7
.changeset/fn-7782-grok-cli-no-response.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface GrokSession {
|
||||
model: string;
|
||||
systemPrompt?: string;
|
||||
messages: unknown[];
|
||||
state: { errorMessage?: string; messages: unknown[] };
|
||||
sessionId?: string;
|
||||
lastModelDescription: string;
|
||||
callbacks: GrokCallbacks;
|
||||
|
||||
Reference in New Issue
Block a user