FN-7724: bridge Grok CLI tool_use NDJSON events in GrokRuntimeAdapter

Bridges Grok CLI tool execution events (tool_use start/result) from the NDJSON stream into the runtime adapter's onToolStart/onToolEnd callbacks, alongside existing text bridging.

- GrokRuntimeAdapter.promptWithFallback now parses and bridges tool_use NDJSON events into onToolStart/onToolEnd callbacks
- Tool name/args/result pass through unchanged (no Grok→pi tool-name mapping, since the verified contract doesn't pin a vocabulary)
- step_finish/error remain non-terminal per-step events, not bridged to any callback; only subprocess close/error finalizes (unchanged from FN-7722)
- Extended stream-parser.ts to recognize tool_use event shapes
- Added new types for tool event payloads in types.ts
- Updated docs/grok-cli-contract.md and plugin README to document tool event bridging
- Added changeset for @runfusion/fusion (minor)
- Added/extended tests in runtime-adapter.test.ts and stream-parser.test.ts (fixture-based, no live binary)

Files changed:
 .changeset/fn-7724-grok-cli-tool-bridging.md       |   7 ++
 docs/grok-cli-contract.md                          |  16 ++-
 plugins/fusion-plugin-grok-runtime/README.md       |  12 +++
 .../src/__tests__/runtime-adapter.test.ts          | 120 +++++++++++++++++++++
 .../src/__tests__/stream-parser.test.ts            |  33 ++++++
 .../src/runtime-adapter.ts                         |  83 +++++++++++---
 .../src/stream-parser.ts                           |  10 ++
 plugins/fusion-plugin-grok-runtime/src/types.ts    |  23 ++++
 8 files changed, 287 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7724

Fusion-Task-Lineage: 73abbf2a-6dcd-44fb-86be-71d4788c92d2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 09:07:15 -07:00
parent e5c3ffbb39
commit 1fc615d0da
8 changed files with 287 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Grok CLI runtime now bridges tool execution events (name/args/result) from the NDJSON stream, not just text.
category: feature
dev: GrokRuntimeAdapter.promptWithFallback now bridges tool_use NDJSON events into onToolStart/onToolEnd; tool name/args/result pass through unchanged (no Grok→pi name mapping — the verified docs/grok-cli-contract.md schema does not pin a tool-name vocabulary). step_finish/error remain non-terminal per-step events and are not bridged to a callback; only subprocess close/error finalizes, unchanged from FN-7722. Fixture-tested (no live binary). End-to-end runtimeHint="grok" routing remains FN-7725's scope; the direct xAI path (FN-7711/7714) is unchanged.

View File

@@ -253,10 +253,18 @@ See the task's `fn_task_create` calls (linked from FN-7722) for:
1. ~~End-to-end routing wiring~~ — **closed by FN-7725** (see "Wiring"
above): the agent Runtime-mode picker path was formalized, documented,
and covered by `packages/engine/src/__tests__/grok-runtime-routing.test.ts`.
2. Full tool-call/break-early bridging for `tool_use` NDJSON events, if a
future need for Grok-CLI-driven tool execution arises (out of scope for
the scoped text/no-thinking adapter landed here; tracked separately as
FN-7724).
2. ~~Full tool-call bridging for `tool_use` NDJSON events~~ — **closed by
FN-7724**: `GrokRuntimeAdapter` now bridges `tool_use` into
`onToolStart`/`onToolEnd` (no Grok→pi tool-name mapping was added — the
verified schema does not pin grok-cli's tool-name vocabulary, so
names/args pass through unchanged). Break-early on `step_finish` was
deliberately NOT adopted: this doc's own "Verified NDJSON event schema"
notes above establish `step_finish` is a per-step boundary (a run can
contain multiple `step_start`/`step_finish` pairs), not the run
terminal — the adapter's terminal signal remains subprocess
`close`/`error`, unchanged from FN-7722. See
`plugins/fusion-plugin-grok-runtime/README.md`'s "Tool execution
bridging (FN-7724)" section.
3. (Filed by FN-7725, if warranted) Preserving a specific `grok-cli/*` model
selection when routing through the CLI runtime (Runtime-mode is currently
model-agnostic — see "Known limitation" in "Wiring" above). This is the

View File

@@ -59,6 +59,18 @@ grok --prompt "<text>" --format json
- The adapter parses that stream (`src/stream-parser.ts`) and drives
`onText` as `text` events arrive. There is no `thinking`/`reasoning` event
in the verified schema, so `onThinking` is never invoked for this path.
- **Tool execution bridging (FN-7724):** each verified `tool_use` event
(`toolCall`/`toolResult`/`timing`) additionally drives `onToolStart(toolName,
args)` / `onToolEnd(toolName, isError, result)`, mirroring the Droid
plugin's `DroidCallbacks` shape. `toolName`/`args` are
`toolCall.function.name` / parsed `toolCall.function.arguments`;
`isError` derives from `toolResult.success === false`. No Grok→pi
tool-name/arg translation is applied — the verified contract does not pin
grok-cli's specific tool-name vocabulary (unlike Droid's Claude-shaped
names), so names/args pass through unchanged. `step_finish` is a per-step
boundary (a run can contain multiple), not the run terminal, so it does
not finalize the adapter's promise; only subprocess `close`/`error` does,
unchanged from FN-7722.
- **Auth implication:** because the `grok` binary resolves its own
credentials for this path (env var, project `.env`, `grok -k`, or
`~/.grok/user-settings.json`), a CLI-routed selection needs **no

View File

@@ -85,6 +85,126 @@ describe("GrokRuntimeAdapter", () => {
await expect(promise).resolves.toBeUndefined();
});
// FNXC:GrokCli 2026-07-09-00:10: FN-7724 — tool_use bridging coverage.
it("bridges tool_use events into onToolStart/onToolEnd in order with translated args", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ onToolStart, onToolEnd });
const promise = adapter.promptWithFallback(session, "list files");
stdout.write(`${JSON.stringify({ type: "step_start", stepNumber: 1, timestamp: 1 })}\n`);
stdout.write(
`${JSON.stringify({
type: "tool_use",
stepNumber: 1,
timestamp: 2,
toolCall: { id: "tc-1", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } },
toolResult: { success: true, output: "a.ts\nb.ts" },
timing: { startedAt: 1, finishedAt: 2, durationMs: 1 },
})}\n`,
);
stdout.write(
`${JSON.stringify({
type: "step_finish",
stepNumber: 1,
timestamp: 3,
finishReason: "tool_calls",
usage: {},
})}\n`,
);
proc.emit("close", 0, null);
await promise;
expect(onToolStart).toHaveBeenCalledTimes(1);
expect(onToolStart).toHaveBeenCalledWith("bash", { command: "ls" });
expect(onToolEnd).toHaveBeenCalledTimes(1);
expect(onToolEnd).toHaveBeenCalledWith("bash", false, { success: true, output: "a.ts\nb.ts" });
// onToolStart must fire before onToolEnd for the same tool call.
expect(onToolStart.mock.invocationCallOrder[0]).toBeLessThan(onToolEnd.mock.invocationCallOrder[0]);
});
it("marks onToolEnd as an error when toolResult.success is false", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ onToolStart, onToolEnd });
const promise = adapter.promptWithFallback(session, "read missing file");
stdout.write(
`${JSON.stringify({
type: "tool_use",
stepNumber: 1,
timestamp: 2,
toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"x"}' } },
toolResult: { success: false, output: "ENOENT" },
})}\n`,
);
proc.emit("close", 0, null);
await promise;
expect(onToolEnd).toHaveBeenCalledWith("read_file", true, { success: false, output: "ENOENT" });
});
it("handles malformed tool_use arguments without throwing, passing the raw string through", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const onToolStart = vi.fn();
const { session } = await adapter.createSession({ onToolStart });
const promise = adapter.promptWithFallback(session, "hi");
stdout.write(
`${JSON.stringify({
type: "tool_use",
stepNumber: 1,
timestamp: 2,
toolCall: { id: "tc-3", type: "function", function: { name: "bash", arguments: "not-json" } },
toolResult: { success: true },
})}\n`,
);
proc.emit("close", 0, null);
await expect(promise).resolves.toBeUndefined();
expect(onToolStart).toHaveBeenCalledWith("bash", "not-json");
});
it("does not finalize on step_finish alone (per-step, not run-terminal); only close/error finalizes", 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, "multi-round");
let resolved = false;
void promise.then(() => {
resolved = true;
});
stdout.write(
`${JSON.stringify({ type: "step_finish", stepNumber: 1, timestamp: 1, finishReason: "tool_calls", usage: {} })}\n`,
);
await Promise.resolve();
await Promise.resolve();
expect(resolved).toBe(false);
stdout.write(`${JSON.stringify({ type: "text", stepNumber: 2, text: "done", timestamp: 2 })}\n`);
proc.emit("close", 0, null);
await promise;
expect(resolved).toBe(true);
expect(onText).toHaveBeenCalledWith("done");
});
it("never invokes onThinking: the verified grok-cli NDJSON schema has no thinking/reasoning event", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);

View File

@@ -76,4 +76,37 @@ describe("parseLine (Grok CLI NDJSON)", () => {
it("skips a JSON array (not an object)", () => {
expect(parseLine(JSON.stringify([{ type: "text" }]))).toBeNull();
});
// FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additional tool_use/step_finish/
// error coverage for the runtime-adapter bridge (Step 3). The parser itself
// needed no change (see stream-parser.ts's FN-7724 comment); these prove
// the full toolCall/toolResult/timing shape round-trips and malformed tool
// lines are still skipped without throwing.
it("parses a tool_use event with full toolCall/toolResult/timing fields", () => {
const line = JSON.stringify({
type: "tool_use",
sessionID: "sess-2",
stepNumber: 2,
timestamp: 300,
toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } },
toolResult: { success: false, output: "ENOENT" },
timing: { startedAt: 280, finishedAt: 300, durationMs: 20 },
});
const parsed = parseLine(line);
expect(parsed).toEqual({
type: "tool_use",
sessionID: "sess-2",
stepNumber: 2,
timestamp: 300,
toolCall: { id: "tc-2", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } },
toolResult: { success: false, output: "ENOENT" },
timing: { startedAt: 280, finishedAt: 300, durationMs: 20 },
});
});
it("skips a malformed tool_use line (broken JSON) without throwing", () => {
const line = '{"type":"tool_use","toolCall":{"function":{"name":';
expect(() => parseLine(line)).not.toThrow();
expect(parseLine(line)).toBeNull();
});
});

View File

@@ -12,15 +12,26 @@ prose: src/index.ts's CLI parsing + src/headless/output.ts's
`createHeadlessJsonlEmitter` + its fixture tests). Contract captured in
docs/grok-cli-contract.md. This adapter spawns that command via the
`cli-stream` seam, parses NDJSON via `stream-parser.parseLine`, and drives
`onText` as `text` events arrive. Scoped deliberately narrow, mirroring the
Droid plugin's parser+text-bridge pattern but NOT its full tool-call/
break-early machinery: the verified schema has no thinking/reasoning event
(onThinking is therefore never invoked here — kept only for AgentRuntime
interface parity), and tool_use bridging is filed as a follow-up (see
docs/grok-cli-contract.md "Follow-ups"). This adapter is only reached when
an agent's `runtimeConfig.runtimeHint === "grok"`, which nothing in the
product sets today (recorded as the wiring gap in the contract doc) — this
task lands the adapter without wiring an end-to-end exercised path.
`onText` as `text` events arrive.
FNXC:GrokCli 2026-07-09-00:10:
FN-7724: extends the above with `tool_use` (and terminal `step_finish`/
`error`) bridging, per docs/grok-cli-contract.md's verified NDJSON schema.
`onToolStart`/`onToolEnd` fire from each `tool_use` event's
`toolCall`/`toolResult`, mirroring the Droid plugin's `DroidCallbacks`
shape. No Grok→pi tool-name/arg mapping is applied: the verified contract
does not pin grok-cli's specific tool-name vocabulary (unlike Droid's
Claude-shaped names), so `toolCall.function.name`/parsed `.arguments` pass
through unchanged (decision recorded in the FN-7724 `research` task
document). `onThinking` is still never invoked — the verified schema has no
thinking/reasoning event (confirmed absence, not a gap). The terminal
lifecycle is UNCHANGED from FN-7722: the doc states `step_finish` is a
per-step boundary (multiple can occur per run for multi-round tool use), so
it does NOT finalize the promise here; only subprocess `close`/`error`
does, same `streamEnded`-guarded (via the existing `settled` flag)
resolve-never-reject lifecycle as before. This adapter is only reached when
an agent's `runtimeConfig.runtimeHint === "grok"` (wired end-to-end by
FN-7725).
*/
/**
@@ -41,6 +52,25 @@ const FIRST_LINE_TIMEOUT_MS = 60_000;
*/
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
/**
* FNXC:GrokCli 2026-07-09-00:10:
* FN-7724: `toolCall.function.arguments` is a JSON-encoded string per the
* verified `ToolCall` shape (docs/grok-cli-contract.md / types.ts's
* `GrokToolCallLike`). Parse it defensively — malformed/missing arguments
* must never throw inside the NDJSON read loop; fall back to the raw string
* (or undefined) so callers still see something rather than losing the
* event, mirroring the Droid event-bridge's empty-args guard.
*/
function parseToolArguments(raw: string | undefined): unknown {
if (raw === undefined) return undefined;
if (raw === "") return {};
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export interface GrokRuntimeAdapterOptions {
/** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */
binary?: string;
@@ -59,7 +89,16 @@ export class GrokRuntimeAdapter implements AgentRuntime {
this.spawnFn = options?.spawn ?? spawnGrokStream;
}
async createSession(options: { defaultModelId?: string; systemPrompt?: string; onText?: (text: string) => void; onThinking?: (text: string) => void } = {}): Promise<AgentSessionResult> {
async createSession(
options: {
defaultModelId?: string;
systemPrompt?: string;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
} = {},
): Promise<AgentSessionResult> {
const model = options.defaultModelId ?? "grok/default";
const session: GrokSession = {
model,
@@ -70,6 +109,8 @@ export class GrokRuntimeAdapter implements AgentRuntime {
callbacks: {
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
};
return { session, sessionFile: undefined };
@@ -133,10 +174,26 @@ export class GrokRuntimeAdapter implements AgentRuntime {
if (event.type === "text") {
grokSession.callbacks.onText?.(event.text);
} else if (event.type === "tool_use") {
// FNXC:GrokCli 2026-07-09-00:10: FN-7724 — bridge the verified
// tool_use event. toolCall.function.name/arguments and
// toolResult.success/output are the verified fields
// (docs/grok-cli-contract.md); pass-through, no name/arg mapping
// (see FN-7724 research task document for the decision).
const toolName = event.toolCall?.function?.name ?? event.toolCall?.type ?? "unknown";
const args = parseToolArguments(event.toolCall?.function?.arguments);
grokSession.callbacks.onToolStart?.(toolName, args);
const isError = event.toolResult?.success === false;
grokSession.callbacks.onToolEnd?.(toolName, isError, event.toolResult);
}
// step_start / tool_use / step_finish / error: intentionally not
// bridged by this scoped adapter (text-only). tool_use bridging is
// a follow-up (docs/grok-cli-contract.md).
// 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).
});
proc.on("error", () => {

View File

@@ -11,6 +11,16 @@ throws. Debug noise, empty lines, and malformed/unrecognized JSON all return
null so the streaming pipeline can safely skip them and continue.
*/
/*
FNXC:GrokCli 2026-07-09-00:10:
FN-7724: confirmed at execution time — FN-7722 already typed `tool_use` /
`step_finish` / `error` into the `GrokNdjsonEvent` union (types.ts) and this
parser already accepts them via KNOWN_EVENT_TYPES below, so no parser change
was needed to "surface" them; only runtime-adapter.ts's bridge (previously
intentionally dropping tool_use/step_finish/error, see FN-7722 comment
above) needed extending. See docs/grok-cli-contract.md for the verified
schema this parser accepts unmodified.
*/
const KNOWN_EVENT_TYPES = new Set(["step_start", "text", "tool_use", "step_finish", "error"]);
/**

View File

@@ -82,6 +82,25 @@ export interface GrokCallbacks {
* event to bridge (see docs/grok-cli-contract.md).
*/
onThinking?: (text: string) => void;
/**
* FNXC:GrokCli 2026-07-09-00:10:
* FN-7724: bridged from the verified `tool_use` NDJSON event's
* `toolCall.function.name` / parsed `toolCall.function.arguments`.
* Mirrors the Droid plugin's `DroidCallbacks.onToolStart` signature. No
* Grok→pi tool-name mapping is applied — the verified contract
* (docs/grok-cli-contract.md) does not pin grok-cli's specific tool-name
* vocabulary, so names/args pass through unchanged (see FN-7724 research
* task document for the decision).
*/
onToolStart?: (toolName: string, args?: unknown) => void;
/**
* FNXC:GrokCli 2026-07-09-00:10:
* FN-7724: bridged from the same `tool_use` event's `toolResult` field —
* `isError` derives from the verified `toolResult.success === false`,
* `result` is the full `toolResult` object (includes `output` plus any
* other verified/unverified passthrough fields).
*/
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
}
export interface GrokSession {
@@ -101,6 +120,10 @@ export interface AgentRuntimeOptions {
defaultModelId?: string;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
/** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolStart. */
onToolStart?: (toolName: string, args?: unknown) => void;
/** FNXC:GrokCli 2026-07-09-00:10: FN-7724 — additive, mirrors GrokCallbacks.onToolEnd. */
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
signal?: AbortSignal;
}