Narrative: the streaming-json headless contract intermittently emitted only thought events then stopReason:Cancelled with zero text, leaving Chat replies silently empty; the adapter now spawns grok with --output-format json, buffers stdout, and parses the single JSON response on process close, with streaming-json parsing kept only as a diagnostic fallback.
- Change grok CLI invocation from --output-format streaming-json to --output-format json (cli-stream.ts)
- Add GrokCliJsonResponse type ({text, stopReason, sessionId, requestId, thought}) and parseJsonOutput() to stream-parser.ts, keeping legacy NDJSON line parsing for fallback/diagnostics
- Rework runtime-adapter.ts to buffer full stdout, parse it via parsePromptOutput (JSON object first, NDJSON fallback), and surface a formatTerminalNoTextDiagnostic when a non-EndTurn stopReason yields no assistant text
- Rename first-line/inactivity timeout bookkeeping from line-based to output/chunk-based (FIRST_OUTPUT_TIMEOUT_MS, firstOutputReceived, firstStdoutChunk) since stdout is no longer consumed via readline
- Update cli-stream/runtime-adapter/stream-parser tests to cover the JSON response path and the Cancelled/no-text diagnostic
- Update docs/grok-cli-contract.md and plugin README to document the json output-format contract and diagnostics
- Add changeset fn-7796-grok-cli-reliable-headless.md (patch, fix)
Files changed:
.changeset/fn-7796-grok-cli-reliable-headless.md | 7 +
docs/grok-cli-contract.md | 108 ++++++++-----
plugins/fusion-plugin-grok-runtime/README.md | 16 +-
.../src/__tests__/cli-stream.test.ts | 4 +-
.../src/__tests__/runtime-adapter.test.ts | 73 ++++++++-
.../src/__tests__/stream-parser.test.ts | 80 +++++----
.../fusion-plugin-grok-runtime/src/cli-stream.ts | 14 +-
.../src/runtime-adapter.ts | 180 ++++++++++++---------
.../src/stream-parser.ts | 65 ++++++--
plugins/fusion-plugin-grok-runtime/src/types.ts | 13 +-
10 files changed, 373 insertions(+), 187 deletions(-)
Fusion-Task-Id: FN-7796
Fusion-Task-Lineage: c920fcf0-98f8-42ec-867a-7f76c0aca1b7
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
49 lines
2.0 KiB
TypeScript
49 lines
2.0 KiB
TypeScript
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
import type { Readable } from "node:stream";
|
|
|
|
/*
|
|
FNXC:GrokCli 2026-07-10-12:50:
|
|
FN-7796: the operator-installed binary is xAI's Grok Build TUI (`grok 0.2.93`). Its `--output-format streaming-json` mode intermittently ends `stopReason:"Cancelled"` with zero `text` events, so Fusion's headless prompt path uses the reliable single-object contract, `grok -p <prompt> --output-format json [-m <model>] [--cwd <dir>]`. Keep foreground piped stdio and Windows shell handling so the adapter can buffer stdout, parse the object on close, and surface close/stderr diagnostics without raw detached processes.
|
|
*/
|
|
|
|
export type GrokStreamProcess = ChildProcessByStdio<null, Readable, Readable>;
|
|
|
|
export interface SpawnGrokStreamOptions {
|
|
cwd?: string;
|
|
model?: string;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* Spawn `grok -p <prompt> --output-format json [-m <model>] [--cwd <cwd>]`
|
|
* with piped stdio. The adapter buffers stdout and parses the complete
|
|
* single-object response on subprocess close.
|
|
*/
|
|
export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess {
|
|
const args: string[] = ["-p", prompt, "--output-format", "json"];
|
|
const model = options?.model?.trim();
|
|
if (model) {
|
|
// FNXC:GrokCliRouting 2026-07-10-10:49: FN-7790 keeps FN-7753's concrete `grok-cli/*` model preservation but uses xAI Grok Build TUI's accepted short flag, `-m <model>`, with the provider prefix stripped by runtime-adapter.ts.
|
|
args.push("-m", model);
|
|
}
|
|
if (options?.cwd) {
|
|
args.push("--cwd", options.cwd);
|
|
}
|
|
|
|
return spawn(binary, args, {
|
|
cwd: options?.cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
shell: process.platform === "win32",
|
|
signal: options?.signal,
|
|
}) as GrokStreamProcess;
|
|
}
|
|
|
|
/** Force-kill a Grok CLI streaming subprocess. Best-effort; never throws. */
|
|
export function forceKillGrokStream(proc: GrokStreamProcess): void {
|
|
try {
|
|
proc.kill("SIGKILL");
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|