diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index 3d6a994a9b6..a77f9b75843 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -506,6 +506,25 @@ describe("ACP turns (fake CLI)", () => { expect(JSON.parse(readFileSync(without, "utf8")).argv).not.toContain("--reasoning-effort"); }); + + it("puts Grok -m after agent so ACP stdio binds the local slug", async () => { + const dump = join(scratch, "grok-argv-order.json"); + await create(GrokAgentDriver); + process.env.FAKE_ACP_DUMP = dump; + await instance.adapter.sendTurn({ threadId: "t-argv", text: "hi", model: "grok-4.5", effort: "high" }); + await recorder.until((e) => e.type === "turn.completed"); + + const argv = JSON.parse(readFileSync(dump, "utf8")).argv as string[]; + const agent = argv.indexOf("agent"); + const modelFlag = argv.indexOf("-m"); + const stdio = argv.indexOf("stdio"); + expect(agent).toBeGreaterThan(-1); + expect(modelFlag).toBeGreaterThan(agent); + expect(stdio).toBeGreaterThan(modelFlag); + expect(argv[modelFlag + 1]).toBe("grok-4.5"); + expect(argv.indexOf("--reasoning-effort")).toBeGreaterThan(agent); + expect(argv.indexOf("--permission-mode")).toBeLessThan(agent); + }); }); describe("ACP snapshot", () => { diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 0bbc999eaeb..7e0bf73b3f9 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -590,6 +590,10 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver config, turn: cliTurn, }); + // initialize's currentModelId is the CLI default (grok-4.6), + // not the model this turn asked for. After a successful pin, + // report the slug we set so the UI does not claim otherwise. + if (!selectedModel && cliTurn.model) selectedModel = cliTurn.model; } } catch (error) { // session.started is the only place the resume cursor is recorded, diff --git a/server/drivers/acp/grok.ts b/server/drivers/acp/grok.ts index ce6554a0e8e..d6956a4e789 100644 --- a/server/drivers/acp/grok.ts +++ b/server/drivers/acp/grok.ts @@ -205,20 +205,41 @@ const support: AcpSupport = { signInCommand: "grok login", }, - // --permission-mode must always be explicit: ~/.grok/config.toml may set - // permission_mode = "always-approve", which would silently make every - // session yolo and never fire session/request_permission. + // Write the [model.slug] block with the instance HOME/GROK_HOME, then pass + // the slug on argv. spawnArgs must not call ensureGrokInjectSlug itself — + // that helper defaults to process.env and would miss the instance override. + resolveTurnModel: (model, env) => (model ? ensureGrokInjectSlug(model, env) : model), + + // --permission-mode is a global grok flag. -m and --reasoning-effort are + // agent flags: Grok 1.0.6 only applies them when they sit AFTER `agent` + // and BEFORE `stdio` (`grok agent -m slug stdio`). Putting -m first is + // accepted as a TUI option and then ignored, so ACP session/new keeps + // [models].default (grok-4.6) and oMLX never sees a request. spawnArgs: (config, turn) => [ "--permission-mode", config.fullAuto ? "bypassPermissions" : "default", - ...(turn.model ? ["-m", ensureGrokInjectSlug(turn.model)] : []), + "agent", + ...(turn.model ? ["-m", turn.model] : []), // long form on purpose: `--effort` is documented as an alias, and an // alias is the part a CLI is free to rename ...(turn.effort ? ["--reasoning-effort", turn.effort] : []), - "agent", "stdio", ], + // -m on argv is necessary but not sufficient: session/new still starts on + // [models].default. Pin the slug over the wire, same as Hermes/Droid. + async configureSession({ request, sessionId, turn }) { + if (!turn.model) return; + try { + await request("session/set_model", { sessionId, modelId: turn.model }); + } catch (e) { + throw new Error( + `Grok rejected model "${turn.model}" via session/set_model: ${(e as Error).message}. ` + + `Check that grok is current (1.0.6+ supports it) and that this slug exists in ~/.grok/config.toml.`, + ); + } + }, + // The CLI owns its own grok.com login; a leaked API key silently flips // billing from the subscription to pay-as-you-go. transformEnv: (env) => { diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index 7e7ab60dcec..34f456d2a45 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -5,13 +5,14 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { DroidAgentDriver, ensureDroidInjectModel } from "./acp/droid.ts"; -import { ensureGrokInjectSlug } from "./acp/grok.ts"; +import { ensureGrokInjectSlug, GrokAgentDriver } from "./acp/grok.ts"; import { ensureKimiInjectAlias, KimiAgentDriver } from "./acp/kimi.ts"; import { ensureOpenCodeInjectModel } from "./acp/opencode-go.ts"; import { AntigravityDriver } from "./antigravity.ts"; const FAKE_ACP = join(dirname(fileURLToPath(import.meta.url)), "..", "testing", "fake-acp-cli.ts"); const FAKE_AGY = join(dirname(fileURLToPath(import.meta.url)), "..", "testing", "fake-agy-cli.ts"); +import { recordEvents } from "../testing/events.ts"; import { ensureHermesInjectProvider } from "./acp/hermes.ts"; import { ensureQwenInjectModel } from "./acp/qwen.ts"; import { @@ -243,6 +244,47 @@ describe("ensureGrokInjectSlug", () => { const text = readFileSync(join(home, ".grok", "config.toml"), "utf8"); expect(text).toContain(`api_key = "unsloth-secret"`); }); + + it("puts the resolved oMLX slug after agent on the Grok child argv", async () => { + const home = mkdtempSync(join(tmpdir(), "omb-grok-argv-")); + scratchDirs.push(home); + mkdirSync(join(home, ".grok"), { recursive: true }); + const dump = join(home, "dump.json"); + const instance = await GrokAgentDriver.create({ + instanceId: "grok-omlx-argv", + displayName: "Grok", + environment: { HOME: home, FAKE_ACP_DUMP: dump }, + enabled: true, + config: { cli: FAKE_ACP, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ + threadId: "t-omlx", + text: "hi", + model: "omlx::GLM-5.2-fp8", + }); + await recorder.until((e) => e.type === "turn.completed"); + const argv = JSON.parse(readFileSync(dump, "utf8")).argv as string[]; + const agent = argv.indexOf("agent"); + const modelFlag = argv.indexOf("-m"); + expect(agent).toBeGreaterThan(-1); + expect(modelFlag).toBeGreaterThan(agent); + expect(argv.indexOf("stdio")).toBeGreaterThan(modelFlag); + expect(argv[modelFlag + 1]).toBe("omlx-glm-5.2-fp8"); + const configCalls = JSON.parse(readFileSync(`${dump}.config.json`, "utf8")) as Array<{ + method: string; + params: { modelId?: string }; + }>; + expect(configCalls).toContainEqual({ + method: "session/set_model", + params: { sessionId: "fake-acp-session", modelId: "omlx-glm-5.2-fp8" }, + }); + expect(readFileSync(join(home, ".grok", "config.toml"), "utf8")).toContain(`model = "GLM-5.2-fp8"`); + } finally { + await instance.dispose(); + } + }); }); describe("ensureKimiInjectAlias", () => {