FN-7712: fix Grok CLI model list parsing for real grok models output

Fixes the Grok CLI model picker showing raw prompt/preamble text instead of real model names by rewriting parseModelLines to match the actual verified `grok models` output shape.

- Rewrote parseModelLines in process-manager.ts to strip the login/"Default model:"/"Available models:" preamble
- Strip `*`/`-` bullet markers and the `(default)` annotation from each model line
- Preserve existing legacy `id - Label`, columnar, and JSON parsing paths
- Added regression tests covering the real grok models output shape
- Added changeset (patch) documenting the fix

Files changed:
 .changeset/fn-7712-grok-model-parse.md             |  7 ++++
 .../src/__tests__/process-manager.test.ts          | 39 ++++++++++++++++++++++
 .../src/process-manager.ts                         | 34 ++++++++++++-------
 3 files changed, 68 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7712

Fusion-Task-Lineage: 93e34513-07b9-41b3-8b8b-ecdb763b4208

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 00:05:19 -07:00
parent 8dce51fdd9
commit 2580524421
3 changed files with 68 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Grok CLI model picker showing prompt text instead of real model names.
category: fix
dev: Rewrote parseModelLines in fusion-plugin-grok-runtime/process-manager.ts to strip the login/"Default model:"/"Available models:" preamble and `*`/`-` bullet markers plus the `(default)` annotation from verified `grok models` output; legacy `id - Label`, columnar, and JSON paths preserved.

View File

@@ -16,6 +16,20 @@ const DASH_MODELS_OUTPUT = [
const COLUMN_MODELS_OUTPUT = ["grok-4 $5.00/M in", "grok-4-fast $0.20/M in"].join("\n");
// FN-7712: verified real `grok models` output shape (attachment 1871.png) —
// login/session preamble, "Default model:" line, "Available models:" header,
// then a bulleted list with `* <id> (default)` for the active model and
// `- <id>` for the rest.
const REAL_BULLETED_OUTPUT = [
"You are logged in with grok-cli v1.2.3",
"Default model: grok-4.5",
"Available models:",
"* grok-4.5 (default)",
"- grok-composer-2.5-fast",
"- grok-4-fast-reasoning",
"- grok-3-mini",
].join("\n");
describe("discoverGrokModels", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -39,6 +53,31 @@ describe("discoverGrokModels", () => {
expect(result.fallbackUsed).toBe(false);
});
it("extracts clean model ids from the verified real bulleted output, dropping preamble and markers", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_BULLETED_OUTPUT, stderr: "" });
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4.5", "grok-composer-2.5-fast", "grok-4-fast-reasoning", "grok-3-mini"]);
expect(result.source).toBe("models-text");
expect(result.fallbackUsed).toBe(false);
for (const model of result.models) {
expect(model).not.toMatch(/^[*-]/);
expect(model).not.toMatch(/\(default\)/i);
}
expect(result.models.some((m) => /logged in|default model|available models/i.test(m))).toBe(false);
});
it("strips the ` (default)` suffix marker from a bulleted default-model line", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({
code: 0,
stdout: ["Available models:", "* grok-4.5 (default)"].join("\n"),
stderr: "",
});
const result = await discoverGrokModels("grok");
expect(result.models).toEqual(["grok-4.5"]);
});
it("extracts bare ids from columnar/pricing-separated output", async () => {
vi.mocked(runGrokCommand).mockResolvedValueOnce({ code: 0, stdout: COLUMN_MODELS_OUTPUT, stderr: "" });
const result = await discoverGrokModels("grok");

View File

@@ -1,28 +1,38 @@
import { runGrokCommand } from "./cli-spawn.js";
/*
FNXC:GrokCli 2026-07-08-00:00:
FN-7705: the exact `grok models` output line shape is
`upstream-pending-verification` — the upstream README documents the command
lists available Grok models "with pricing hints" but does not pin an exact
column/separator format. We parse CONSERVATIVELY: strip obvious header/tip/
empty-state lines, then take the leading token before a ` - ` label
separator (mirroring the Cursor CLI's `<id> - <Label>` shape) or, absent
that, before the first run of 2+ spaces (a common columnar pricing-hint
layout), falling back to the whole trimmed line as a single-token id.
Defensive JSON-tolerant parsing is attempted first (mirroring Cursor's
process-manager.ts), even though the real CLI is not known to emit JSON.
FNXC:GrokCli 2026-07-08-19:30:
FN-7712: the `grok models` output shape is now VERIFIED from a real capture
(attachment 1871.png in FN-7712): the CLI prints a login/session preamble
("You are logged in with grok..."), a "Default model: <id>" line, an
"Available models:" header, then a bulleted list — `* <id> (default)` for
the active model and `- <id>` for the rest. We drop the preamble/header
lines (case-insensitively, tolerating a trailing colon and leading/trailing
whitespace), strip a leading `* `/`- ` bullet marker from each remaining
candidate line, then strip a trailing ` (default)`/`(default)` annotation
before id extraction so the picker only ever sees bare model ids. Legacy
extraction (leading token before a ` - ` label separator, else before the
first run of 2+ spaces for columnar/pricing layouts, else the whole trimmed
token) is preserved unchanged so older `id - Label (pricing)` and columnar
fixtures still parse. Defensive JSON-tolerant parsing is attempted first
(mirroring Cursor's process-manager.ts), even though the real CLI is not
known to emit JSON.
*/
function parseModelLines(raw: string): string[] {
const ids = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !/^available models$/i.test(line))
.filter((line) => !/^available models:?$/i.test(line))
.filter((line) => !/^you are logged in\b/i.test(line))
.filter((line) => !/^default model:?/i.test(line))
.filter((line) => !/^models?:?$/i.test(line))
.filter((line) => !/^no models? available/i.test(line))
.filter((line) => !/^tip:/i.test(line))
.filter((line) => !/^usage/i.test(line))
.map((line) => line.replace(/^[*-]\s+/, ""))
.map((line) => line.replace(/\s*\(default\)\s*$/i, ""))
.map((line) => line.trim())
.map((line) => {
const dashIndex = line.indexOf(" - ");
if (dashIndex !== -1) return line.slice(0, dashIndex).trim();