mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(agent): add Grok Build CLI as an ACP runtime (#5285)
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP (`grok --no-auto-update agent --always-approve stdio`), reusing hermesClient like traecli and kimi. Includes daemon discovery, protocol_family migration 174, model discovery, MCP passthrough, thinking effort, frontend branding, and product docs. Follows xAI's documented headless ACP flow: after `initialize`, read the advertised `authMethods` and send `authenticate` (preferring xai.api_key when XAI_API_KEY is set, else the cached login token) before any session operation — a real, logged-in CLI rejects session/new and session/load without it. Model discovery performs the same handshake so it returns the live catalog instead of the static fallback. `--no-auto-update` is passed as a global flag (and kept daemon-owned in the blocked custom-arg set) so a background update check can't stall an unattended ACP task. Thinking effort uses the current `--effort` flag, and the minimum grok version is 0.2.89 (ACP + authenticate + session/load + session/set_model + MCP + --effort). Closes #2895
This commit is contained in:
@@ -151,6 +151,7 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
| [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent |
|
||||
| [Trae](https://docs.trae.cn/cli) | `traecli` | ByteDance TRAE CLI (ACP via `traecli acp serve`) |
|
||||
| [Grok Build CLI](https://docs.x.ai/) | `grok` | xAI Grok Build CLI (ACP via `grok agent stdio`) |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -226,6 +227,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_QODER_MODEL` | Override the Qoder model used |
|
||||
| `MULTICA_TRAECLI_PATH` | Custom path to the `traecli` binary |
|
||||
| `MULTICA_TRAECLI_MODEL` | Override the Trae model used (a model id from your logged-in traecli catalog, e.g. `Doubao-Seed-2.1-Pro`) |
|
||||
| `MULTICA_GROK_PATH` | Custom path to the `grok` binary (defaults to `grok` on PATH; often `~/.grok/bin/grok`) |
|
||||
| `MULTICA_GROK_MODEL` | Override the Grok model used (e.g. `grok-4.5`) |
|
||||
|
||||
If a previously generated `~/.multica/hooks` wrapper is first on `PATH` and calls the same command name again, the daemon skips that hooks directory during built-in agent discovery and records the real binary path behind it. If your interactive shell still recurses when you run `claude`, `codex`, or `hermes` manually, remove the hooks entry from your shell startup file or replace the wrapper body with an absolute `exec /path/to/real-binary "$@"`.
|
||||
|
||||
|
||||
+4
-4
@@ -166,12 +166,12 @@ Wait 3 seconds, then verify:
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, `grok`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -185,12 +185,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, or `grok`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -96,12 +96,13 @@ You also need at least one AI agent CLI installed:
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
- Qoder CLI (`qodercli` on PATH)
|
||||
- Trae CLI (`traecli` on PATH)
|
||||
- [Grok Build CLI](https://docs.x.ai/) (`grok` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
|
||||
@@ -146,12 +146,12 @@ Agent-specific overrides:
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
| `MULTICA_GROK_PATH` | Custom path to the `grok` binary |
|
||||
| `MULTICA_GROK_MODEL` | Override the Grok model used (e.g. `grok-4.5`) |
|
||||
|
||||
## Database Setup
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
|
||||
|
||||
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
|
||||
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
|
||||
- **AI coding tools** — one of the fifteen (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
- **AI coding tools** — one of the sixteen (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code), [Grok](/providers#grok). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
|
||||
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
|
||||
|
||||
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
|
||||
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Fifteen are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code). Your API keys, toolchain, and code directories stay on your machine.
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Sixteen are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [CodeBuddy](/providers#codebuddy), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi), [Qoder](/providers#qoder), [Trae CLI](/providers#trae), [DevEco Code](/providers#deveco-code), [Grok](/providers#grok). Your API keys, toolchain, and code directories stay on your machine.
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 15 supported tools so the daemon can detect them.
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 16 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 15 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 16 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
@@ -31,9 +31,9 @@ multica daemon restart
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 15 supported tools
|
||||
## The 16 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 15.
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 16.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
@@ -192,6 +192,17 @@ Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and ru
|
||||
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
|
||||
|
||||
### Grok (xAI)
|
||||
|
||||
xAI's Grok Build CLI (`grok`). Multica drives it over ACP via `grok --no-auto-update agent --always-approve stdio`, sharing the transport with Hermes, Kimi, Kiro CLI, Qoder, and Trae. It completes xAI's headless `authenticate` handshake after `initialize`, before any session operation. Session resumption works through ACP `session/load`. Models are discovered from `session/new`. MCP config is passed through ACP `mcpServers`. Skills are copied into `.grok/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `grok` |
|
||||
| Install | Install the Grok Build CLI and ensure `grok` is on `PATH` (often `~/.grok/bin`). |
|
||||
| Authentication | Run `grok login` (or complete the interactive login flow) so ACP sessions can authenticate. |
|
||||
| Notes | Override the binary with `MULTICA_GROK_PATH` and the default model with `MULTICA_GROK_MODEL` (e.g. `grok-4.5`). Minimum CLI version: `0.2.89`. |
|
||||
|
||||
## After installing
|
||||
|
||||
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`, `which agy`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI coding tools matrix
|
||||
description: Multica supports 15 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
description: Multica supports 16 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica ships with built-in support for **15 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
Multica ships with built-in support for **16 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
|
||||
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
|
||||
|
||||
@@ -28,6 +28,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
| **Qoder** | Alibaba | ✅ | ✅ | `.qoder/skills/` | Dynamic discovery |
|
||||
| **Trae** | ByteDance | ✅ (ACP `session/load`) | ✅ | `.traecli/skills/` | Dynamic discovery |
|
||||
| **Grok** | xAI | ✅ (ACP `session/load`) | ✅ | `.grok/skills/` | Dynamic discovery (ACP `session/new`) |
|
||||
|
||||
## What each tool is for
|
||||
|
||||
@@ -108,15 +109,19 @@ From ByteDance — the official **TRAE CLI** (`traecli`, documented at [docs.tra
|
||||
|
||||
> The capabilities above were captured from the real `traecli` v0.120.42 binary: `initialize` → `loadSession:true` + `mcpCapabilities{http,sse}`; `session/new` → `result.sessionId` + `models.availableModels`; `session/prompt` streams `session/update` notifications with `sessionUpdate: agent_message_chunk`.
|
||||
|
||||
### Grok
|
||||
|
||||
From xAI. CLI binary name is `grok` (Grok Build CLI, often installed to `~/.grok/bin/grok`). Multica drives it over **ACP** via `grok --no-auto-update agent --always-approve stdio`, reusing the same transport as Trae / Kimi / Kiro / Qoder. After `initialize` Multica completes xAI's headless **auth handshake** (`authenticate`, preferring `xai.api_key` when `XAI_API_KEY` is set, else the cached login token) before any session operation. **Session resumption works** through ACP `session/load`. **Model selection works** via the catalog returned from `session/new` and `session/set_model`. **Reasoning effort** is exposed through Multica’s thinking picker and passed as `--effort` on the `agent` command. **MCP is supported** — config is passed through ACP `mcpServers` (filtered to transports advertised by `initialize`). Skills land in `.grok/skills/` (project) and `~/.grok/skills` (user); the CLI also scans `.agents/skills/`. Tool calls stream as ACP `tool_call` / `tool_call_update` events.
|
||||
|
||||
## Session resumption: who really supports it
|
||||
|
||||
The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continue-from-the-previous-context). **Every supported tool resumes sessions** — pass the resume id and the task continues from the previous context. The one quirk is Pi, whose resume id is a session file path on disk rather than a string id (see [Pi](#pi) above).
|
||||
|
||||
## MCP configuration: provider-specific support
|
||||
|
||||
**Of the 15 tools, eleven consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder, and Trae**. The other four (Antigravity, Copilot, DevEco Code, Pi) accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 16 tools, twelve consume `mcp_config`: Claude Code, CodeBuddy, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Qoder, Trae, and Grok**. The other four (Antigravity, Copilot, DevEco Code, Pi) accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, Qoder, and Trae receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
The runtime paths are provider-specific: Claude Code and CodeBuddy receive it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, Kiro CLI, Qoder, Trae, and Grok receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
|
||||
@@ -139,6 +144,8 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| DevEco Code | `.deveco/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Qoder | `.qoder/skills/` | ✅ Native |
|
||||
| Trae | `.traecli/skills/` | ✅ Native |
|
||||
| Grok | `.grok/skills/` | ✅ Native |
|
||||
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
|
||||
| Hermes | `HERMES_HOME/skills/` (per-task home) | ✅ Native (seeded per task from `~/.hermes/`) |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
@@ -154,4 +161,4 @@ For creating and using skills, see [Skills](/skills).
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
|
||||
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
|
||||
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 15 supported tools
|
||||
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 16 supported tools
|
||||
|
||||
@@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent
|
||||
|
||||
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
|
||||
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`)
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `qodercli`, `traecli`, `grok`)
|
||||
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
|
||||
3. 每 3 秒 **轮询** 一次 server,有任务就认领
|
||||
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("providerSupportsMcpConfig", () => {
|
||||
expect(providerSupportsMcpConfig("openclaw")).toBe(true);
|
||||
expect(providerSupportsMcpConfig("qoder")).toBe(true);
|
||||
expect(providerSupportsMcpConfig("traecli")).toBe(true);
|
||||
expect(providerSupportsMcpConfig("grok")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects providers whose runtime ignores mcp_config", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ const MCP_SUPPORTED_PROVIDERS = new Set([
|
||||
"codebuddy",
|
||||
"codex",
|
||||
"cursor",
|
||||
"grok",
|
||||
"hermes",
|
||||
"kimi",
|
||||
"kiro",
|
||||
|
||||
@@ -119,6 +119,7 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
|
||||
"antigravity",
|
||||
"qoder",
|
||||
"traecli",
|
||||
"grok",
|
||||
] as const;
|
||||
|
||||
export type RuntimeProtocolFamily =
|
||||
|
||||
@@ -268,6 +268,21 @@ function TraeLogo({ className }: { className: string }) {
|
||||
return <img src={TRAE_ICON} alt="Trae" className={className} />;
|
||||
}
|
||||
|
||||
// Grok (xAI) — simple monochrome wordmark tile (G on dark square). Matches the
|
||||
// compact logo style used for Pi / OpenCode so the runtime list stays dense.
|
||||
function GrokLogo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect width="24" height="24" rx="5" fill="#0A0A0A" />
|
||||
<path
|
||||
d="M7.2 7.2h4.1c2.7 0 4.5 1.7 4.5 4.2 0 2.5-1.8 4.2-4.5 4.2H9.4v1.2H7.2V7.2zm2.2 2v4.2h1.8c1.5 0 2.4-.9 2.4-2.1 0-1.2-.9-2.1-2.4-2.1H9.4z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
<path d="M16.8 16.8 14.2 14.1" stroke="#FFFFFF" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className = "h-4 w-4",
|
||||
@@ -306,6 +321,8 @@ export function ProviderLogo({
|
||||
return <AntigravityLogo className={className} />;
|
||||
case "traecli":
|
||||
return <TraeLogo className={className} />;
|
||||
case "grok":
|
||||
return <GrokLogo className={className} />;
|
||||
default:
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ type Config struct {
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
@@ -326,8 +326,14 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
if e, ok := probe("MULTICA_TRAECLI_PATH", "traecli", "MULTICA_TRAECLI_MODEL"); ok {
|
||||
agents["traecli"] = e
|
||||
}
|
||||
// xAI Grok Build CLI (`grok`), driven over ACP via
|
||||
// `grok agent --always-approve stdio`. MULTICA_GROK_MODEL seeds the
|
||||
// daemon-wide default (e.g. grok-4.5).
|
||||
if e, ok := probe("MULTICA_GROK_PATH", "grok", "MULTICA_GROK_MODEL"); ok {
|
||||
agents["grok"] = e
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, or traecli and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, qodercli, traecli, or grok and ensure it is on PATH")
|
||||
}
|
||||
|
||||
claudeArgs, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS")
|
||||
@@ -826,7 +832,7 @@ func isExecutableFile(path string) bool {
|
||||
// invocation, instead of paying the cost-per-miss.
|
||||
var defaultAgentCommandNames = []string{
|
||||
"claude", "codex", "opencode", "deveco", "openclaw", "hermes",
|
||||
"pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli",
|
||||
"pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", "grok",
|
||||
}
|
||||
|
||||
// codexDesktopAppBundlePaths returns candidate macOS app-bundle locations for
|
||||
|
||||
@@ -837,6 +837,7 @@ func pinNonCodexAgentsToMissingPaths(t *testing.T) {
|
||||
"MULTICA_COPILOT_PATH",
|
||||
"MULTICA_KIMI_PATH",
|
||||
"MULTICA_KIRO_PATH",
|
||||
"MULTICA_GROK_PATH",
|
||||
} {
|
||||
t.Setenv(name, filepath.Join(missingDir, strings.ToLower(name)))
|
||||
}
|
||||
|
||||
@@ -3467,6 +3467,7 @@ func gcMetaForTask(task Task) (execenv.GCMeta, bool) {
|
||||
// here fall back to capitalizing the key (claude → "Claude", codex → "Codex").
|
||||
var runtimeDisplayNameOverrides = map[string]string{
|
||||
"traecli": "Trae",
|
||||
"grok": "Grok",
|
||||
}
|
||||
|
||||
// providerDisplayName returns the human-facing runtime name for a provider key.
|
||||
|
||||
@@ -393,6 +393,11 @@ func skillsDirPath(workDir, provider string) string {
|
||||
// workspace skill layout; see https://antigravity.google/docs/gcli-migration
|
||||
// under "Workspace skills".
|
||||
return filepath.Join(workDir, ".agents", "skills")
|
||||
case "grok":
|
||||
// Grok Build CLI discovers project-level skills from .grok/skills/
|
||||
// (and also scans .agents/skills/). Prefer the native .grok tree.
|
||||
// See Grok user-guide skills.md.
|
||||
return filepath.Join(workDir, ".grok", "skills")
|
||||
default:
|
||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||
return filepath.Join(workDir, ".agent_context", "skills")
|
||||
|
||||
@@ -170,6 +170,7 @@ func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
// For Qoder: writes {workDir}/AGENTS.md (skills discovered from .qoder/skills/, user-level ~/.qoder/skills is unaffected)
|
||||
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
|
||||
// For Traecli: writes {workDir}/AGENTS.md (traecli reads .trae/rules/ not AGENTS.md, so the brief is delivered inline via providerNeedsInlineSystemPrompt; the file is written for parity/visibility only)
|
||||
// For Grok: writes {workDir}/AGENTS.md (Grok Build CLI reads AGENTS.md natively from the workdir)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
path := runtimeConfigPath(workDir, provider)
|
||||
@@ -189,7 +190,7 @@ func runtimeConfigPath(workDir, provider string) string {
|
||||
switch provider {
|
||||
case "claude", "codebuddy":
|
||||
return filepath.Join(workDir, "CLAUDE.md")
|
||||
case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli":
|
||||
case "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok":
|
||||
return filepath.Join(workDir, "AGENTS.md")
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -104,6 +104,7 @@ const (
|
||||
// - Qoder: ~/.qoder/skills mirrors Qoder CLI's project-level .qoder/skills layout
|
||||
// - Antigravity: ~/.gemini/antigravity-cli/skills user-level skill root
|
||||
// (https://antigravity.google/docs/gcli-migration "Global skills")
|
||||
// - Grok: ~/.grok/skills is Grok Build CLI's user-level skill root
|
||||
//
|
||||
// The universal ~/.agents/skills root is documented as a cross-tool skill
|
||||
// location by Codex (https://developers.openai.com/codex/skills) and Gemini
|
||||
@@ -156,6 +157,9 @@ func localSkillRootsForProvider(provider string) ([]localSkillRoot, bool, error)
|
||||
// agy inherits Gemini CLI's global skill root; see
|
||||
// https://antigravity.google/docs/gcli-migration ("Global skills").
|
||||
providerRoot = filepath.Join(home, ".gemini", "antigravity-cli", "skills")
|
||||
case "grok":
|
||||
// Grok Build CLI user-level skills: ~/.grok/skills
|
||||
providerRoot = filepath.Join(home, ".grok", "skills")
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ func TestLocalSkills_DiscoversACPProviderRoots(t *testing.T) {
|
||||
wantPath: "~/.qoder/skills/review-helper",
|
||||
wantName: "Qoder Review",
|
||||
},
|
||||
{
|
||||
provider: "grok",
|
||||
root: filepath.Join(".grok", "skills"),
|
||||
wantPath: "~/.grok/skills/review-helper",
|
||||
wantName: "Grok Review",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -121,6 +121,7 @@ var (
|
||||
"copilot": "copilot",
|
||||
"cursor": "cursor",
|
||||
"gemini": "gemini",
|
||||
"grok": "grok",
|
||||
"hermes": "hermes",
|
||||
"kiro": "kiro",
|
||||
"kimi": "kimi",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check;
|
||||
|
||||
-- Restore the pre-179 whitelist (migration 175 shape: with deveco, without
|
||||
-- grok). NOT VALID keeps the historical Gemini tolerance so the rollback cannot
|
||||
-- fail on old rows.
|
||||
ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check
|
||||
CHECK (protocol_family IN (
|
||||
'claude',
|
||||
'codebuddy',
|
||||
'codex',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
'pi',
|
||||
'cursor',
|
||||
'kimi',
|
||||
'kiro',
|
||||
'antigravity',
|
||||
'qoder',
|
||||
'traecli',
|
||||
'deveco'
|
||||
)) NOT VALID;
|
||||
@@ -0,0 +1,26 @@
|
||||
ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check;
|
||||
|
||||
-- Widen the whitelist to include Grok Build CLI (`grok`), driven over ACP via
|
||||
-- `grok agent --always-approve stdio` (#2895). Builds on migration 175's shape
|
||||
-- (which added `deveco`), so both stay in the whitelist. NOT VALID mirrors
|
||||
-- migrations 126/134/136/175 so a historical Gemini row they intentionally
|
||||
-- tolerated does not block the upgrade.
|
||||
ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check
|
||||
CHECK (protocol_family IN (
|
||||
'claude',
|
||||
'codebuddy',
|
||||
'codex',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
'pi',
|
||||
'cursor',
|
||||
'kimi',
|
||||
'kiro',
|
||||
'antigravity',
|
||||
'qoder',
|
||||
'traecli',
|
||||
'deveco',
|
||||
'grok'
|
||||
)) NOT VALID;
|
||||
+17
-11
@@ -1,7 +1,7 @@
|
||||
// Package agent provides a unified interface for executing prompts via
|
||||
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, DevEco Code,
|
||||
// OpenClaw, Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder). It mirrors the
|
||||
// happy-cli AgentBackend pattern, translated to idiomatic Go.
|
||||
// OpenClaw, Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder, Trae, Grok). It
|
||||
// mirrors the happy-cli AgentBackend pattern, translated to idiomatic Go.
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -53,8 +53,9 @@ type ExecOptions struct {
|
||||
// "use the runtime/model default" —
|
||||
// every backend that consumes this skips its --effort / reasoning_effort
|
||||
// injection so the upstream CLI's own default applies. Currently honoured
|
||||
// by the claude, codex, and opencode backends; other backends ignore the
|
||||
// field rather than fail (so MUL-2339 can grow runtime support
|
||||
// by the claude, codex, opencode, codebuddy, and grok (ACP
|
||||
// `--effort` on `grok agent`) backends; other backends ignore
|
||||
// the field rather than fail (so MUL-2339 can grow runtime support
|
||||
// incrementally without breaking unrelated agents).
|
||||
ThinkingLevel string
|
||||
// OpenclawMode chooses between local (embedded) and gateway routing for
|
||||
@@ -138,25 +139,26 @@ type Result struct {
|
||||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli)
|
||||
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli, traecli, grok)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli".
|
||||
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok".
|
||||
//
|
||||
// SupportedTypes is the canonical whitelist of agent types eligible to back a
|
||||
// custom runtime profile. It MUST stay in lockstep with the
|
||||
// runtime_profile.protocol_family CHECK constraint (migration 120, widened by
|
||||
// migration 134 to add qoder, migration 136 to add traecli, and migration 175
|
||||
// to add deveco): a custom
|
||||
// runtime profile may only be based on a backend Multica officially supports.
|
||||
// migration 134 to add qoder, migration 136 to add traecli, migration 175 to
|
||||
// add deveco, and migration 179 to add grok): a custom runtime profile may only
|
||||
// be based on a backend Multica officially supports.
|
||||
// qoder is exposed here so Qoder CN (`qoderclicn`) users can point the Qoder
|
||||
// backend at a non-default binary instead of misrouting through Kiro/ACP with
|
||||
// incompatible arguments (#4883). traecli (Trae) has a New backend, launch
|
||||
// header and provider branding but was previously missing from this whitelist,
|
||||
// so the family picker rejected it (#4945).
|
||||
// so the family picker rejected it (#4945). grok is the xAI Grok Build CLI
|
||||
// ACP backend (`grok agent --always-approve stdio`).
|
||||
var SupportedTypes = []string{
|
||||
"claude",
|
||||
"codebuddy",
|
||||
@@ -173,6 +175,7 @@ var SupportedTypes = []string{
|
||||
"antigravity",
|
||||
"qoder",
|
||||
"traecli",
|
||||
"grok",
|
||||
}
|
||||
|
||||
// IsSupportedType reports whether agentType is in the SupportedTypes whitelist.
|
||||
@@ -223,8 +226,10 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
return &qoderBackend{cfg: cfg}, nil
|
||||
case "traecli":
|
||||
return &traecliBackend{cfg: cfg}, nil
|
||||
case "grok":
|
||||
return &grokBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli)", agentType)
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +260,7 @@ var launchHeaders = map[string]string{
|
||||
"pi": "pi (json mode)",
|
||||
"qoder": "qodercli --acp",
|
||||
"traecli": "traecli acp serve",
|
||||
"grok": "grok agent stdio",
|
||||
}
|
||||
|
||||
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestSupportedTypesMatchesMigrationWhitelist(t *testing.T) {
|
||||
"claude": true, "codebuddy": true, "codex": true, "copilot": true,
|
||||
"opencode": true, "deveco": true, "openclaw": true, "hermes": true,
|
||||
"pi": true, "cursor": true, "kimi": true, "kiro": true, "antigravity": true,
|
||||
"qoder": true, "traecli": true,
|
||||
"qoder": true, "traecli": true, "grok": true,
|
||||
}
|
||||
if len(SupportedTypes) != len(want) {
|
||||
t.Fatalf("SupportedTypes has %d entries, migration whitelist has %d; keep them in lockstep", len(SupportedTypes), len(want))
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
// entry to launchHeaders in agent.go and extend this list.
|
||||
supported := []string{
|
||||
"antigravity", "claude", "codebuddy", "codex", "copilot", "cursor",
|
||||
"hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder", "traecli",
|
||||
"grok", "hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder", "traecli",
|
||||
}
|
||||
for _, t_ := range supported {
|
||||
if header := LaunchHeader(t_); header == "" {
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// grokBlockedArgs are flags/subcommands hardcoded by the daemon that must not
|
||||
// be overridden by user-configured custom_args. `agent` + `stdio` select the
|
||||
// ACP transport; `--always-approve` is daemon-owned so headless Multica runs
|
||||
// do not block on interactive permission prompts. Switching into
|
||||
// headless/serve/leader/print modes would break the daemon↔grok ACP contract.
|
||||
// Model / thinking are managed via session/set_model and --effort.
|
||||
var grokBlockedArgs = map[string]blockedArgMode{
|
||||
"agent": blockedStandalone,
|
||||
"stdio": blockedStandalone,
|
||||
"headless": blockedStandalone,
|
||||
"serve": blockedStandalone,
|
||||
"leader": blockedStandalone,
|
||||
"--always-approve": blockedStandalone,
|
||||
"--yolo": blockedStandalone,
|
||||
"--no-auto-update": blockedStandalone,
|
||||
"--no-alt-screen": blockedStandalone,
|
||||
"-p": blockedStandalone,
|
||||
"--print": blockedStandalone,
|
||||
"--single": blockedWithValue,
|
||||
"--output-format": blockedWithValue,
|
||||
"--permission-mode": blockedWithValue,
|
||||
"-m": blockedWithValue,
|
||||
"--model": blockedWithValue,
|
||||
"--reasoning-effort": blockedWithValue,
|
||||
"--effort": blockedWithValue,
|
||||
"-r": blockedWithValue,
|
||||
"--resume": blockedWithValue,
|
||||
"-c": blockedStandalone,
|
||||
"--continue": blockedStandalone,
|
||||
"-s": blockedWithValue,
|
||||
"--session-id": blockedWithValue,
|
||||
"--system-prompt-override": blockedWithValue,
|
||||
}
|
||||
|
||||
// grokBackend implements Backend by spawning
|
||||
// `grok --no-auto-update agent --always-approve [--effort <level>] stdio`
|
||||
// and communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0
|
||||
// transport over stdin/stdout.
|
||||
//
|
||||
// This targets xAI's Grok Build CLI (the `grok` binary). Grok Build exposes
|
||||
// ACP via `grok agent stdio` (flags such as --always-approve and
|
||||
// --effort belong on the `agent` command, before the transport
|
||||
// subcommand). We reuse hermesClient (same as traecli/kimi/kiro/qoder) with
|
||||
// provider-specific launch args and tool-name normalization.
|
||||
//
|
||||
// Capability notes: we attempt session/load on resume and session/set_model
|
||||
// when a model is requested; if a particular Grok Build version lacks those
|
||||
// methods, the run fails with a clear error rather than silently continuing
|
||||
// on the wrong session/model. Real initialize (0.2.x) advertises
|
||||
// loadSession:true and mcpCapabilities {http, …}.
|
||||
type grokBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
var grokReaderDrainGrace = 2 * time.Second
|
||||
|
||||
// grokMessageStream serializes sends and the final close so a late stdout
|
||||
// reader cannot send on a closed channel. Mirrors traecli/qoder.
|
||||
type grokMessageStream struct {
|
||||
ch chan Message
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newGrokMessageStream(size int) *grokMessageStream {
|
||||
return &grokMessageStream{ch: make(chan Message, size)}
|
||||
}
|
||||
|
||||
func (s *grokMessageStream) send(msg Message) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
trySend(s.ch, msg)
|
||||
}
|
||||
|
||||
func (s *grokMessageStream) close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
close(s.ch)
|
||||
}
|
||||
|
||||
func (b *grokBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "grok"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("grok executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
// Translate the agent's mcp_config (Claude-style object of objects) into
|
||||
// the array shape ACP session/new and session/load expect. Fail closed on
|
||||
// malformed JSON so the launch surfaces the real error instead of silently
|
||||
// dropping every MCP server.
|
||||
mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grok: invalid mcp_config: %w", err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
runCtx, cancel := runContext(ctx, timeout)
|
||||
|
||||
// Flags on `grok agent` come before the transport subcommand (`stdio`).
|
||||
// Thinking is a process-level flag on the agent command; model is set
|
||||
// after session create via session/set_model (ACP).
|
||||
//
|
||||
// `--no-auto-update` is a *global* flag (before the `agent` subcommand):
|
||||
// xAI recommends it for headless/ACP/CI runs so a background update check
|
||||
// never interferes with an unattended daemon task. It is daemon-owned
|
||||
// (grokBlockedArgs) so user custom_args cannot double it or strip it.
|
||||
grokArgs := []string{"--no-auto-update", "agent", "--always-approve"}
|
||||
if opts.ThinkingLevel != "" {
|
||||
grokArgs = append(grokArgs, "--effort", opts.ThinkingLevel)
|
||||
}
|
||||
grokArgs = append(grokArgs, "stdio")
|
||||
grokArgs = append(grokArgs, filterCustomArgs(opts.CustomArgs, grokBlockedArgs, b.cfg.Logger)...)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, grokArgs...)
|
||||
hideAgentWindow(cmd)
|
||||
b.cfg.Logger.Info("agent command", "exec", execPath, "args", grokArgs)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
childEnv := buildEnv(b.cfg.Env)
|
||||
cmd.Env = childEnv
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("grok stdout pipe: %w", err)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("grok stdin pipe: %w", err)
|
||||
}
|
||||
// StderrPipe + an explicit copier give us a join point (`stderrDone`) that
|
||||
// fires before the failure-promotion decision; see hermes.go for why the
|
||||
// io.MultiWriter form races with stopReason=end_turn under load.
|
||||
providerErr := newACPProviderErrorSniffer("grok")
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("grok stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start grok: %w", err)
|
||||
}
|
||||
|
||||
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[grok:stderr] "), providerErr)
|
||||
stderrDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stderrDone)
|
||||
_, _ = io.Copy(stderrSink, stderr)
|
||||
}()
|
||||
|
||||
b.cfg.Logger.Info("grok acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
||||
|
||||
msgStream := newGrokMessageStream(256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
var outputMu sync.Mutex
|
||||
var output strings.Builder
|
||||
var streamingCurrentTurn atomic.Bool
|
||||
|
||||
promptDone := make(chan hermesPromptResult, 1)
|
||||
|
||||
c := &hermesClient{
|
||||
cfg: b.cfg,
|
||||
stdin: stdin,
|
||||
pending: make(map[int]*pendingRPC),
|
||||
pendingTools: make(map[string]*pendingToolCall),
|
||||
acceptNotification: func(string) bool {
|
||||
return streamingCurrentTurn.Load()
|
||||
},
|
||||
onMessage: func(msg Message) {
|
||||
if !streamingCurrentTurn.Load() {
|
||||
return
|
||||
}
|
||||
if msg.Type == MessageToolUse {
|
||||
// Re-normalise capitalised titles ("Read file: …") the same way
|
||||
// kimi/traecli do so the UI sees consistent snake_case names.
|
||||
msg.Tool = kimiToolNameFromTitle(msg.Tool)
|
||||
}
|
||||
if msg.Type == MessageText {
|
||||
outputMu.Lock()
|
||||
output.WriteString(msg.Content)
|
||||
outputMu.Unlock()
|
||||
}
|
||||
msgStream.send(msg)
|
||||
},
|
||||
onPromptDone: func(result hermesPromptResult) {
|
||||
if !streamingCurrentTurn.Load() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case promptDone <- result:
|
||||
default:
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
c.handleLine(line)
|
||||
}
|
||||
c.closeAllPending(fmt.Errorf("grok process exited"))
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer msgStream.close()
|
||||
defer close(resCh)
|
||||
defer func() {
|
||||
stdin.Close()
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
startTime := time.Now()
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
var sessionID string
|
||||
effectiveModel := strings.TrimSpace(opts.Model)
|
||||
|
||||
initResult, err := c.request(runCtx, "initialize", map[string]any{
|
||||
"protocolVersion": 1,
|
||||
"clientInfo": map[string]any{
|
||||
"name": "multica-agent-sdk",
|
||||
"version": "0.2.0",
|
||||
},
|
||||
"clientCapabilities": map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok initialize failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
|
||||
// Grok's ACP surface requires an explicit auth handshake between
|
||||
// `initialize` and any session operation: read the advertised
|
||||
// authMethods, pick one, and send `authenticate` before session/new
|
||||
// or session/load. Skipping this makes a real, logged-in CLI reject
|
||||
// every session op (the fake ACP in tests happens to accept them,
|
||||
// which is exactly why this must be asserted in tests too). xAI's
|
||||
// documented preference is the API key when XAI_API_KEY is set and
|
||||
// offered, otherwise the cached login token.
|
||||
// Ref: https://docs.x.ai/build/cli/headless-scripting
|
||||
if methodID, ok := selectGrokAuthMethod(extractACPAuthMethods(initResult), envHasNonEmpty(childEnv, "XAI_API_KEY")); ok {
|
||||
if _, err := c.request(runCtx, "authenticate", map[string]any{
|
||||
"methodId": methodID,
|
||||
"_meta": map[string]any{"headless": true},
|
||||
}); err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok authenticate (%s) failed: %v", methodID, err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
b.cfg.Logger.Info("grok authenticated", "method", methodID)
|
||||
}
|
||||
|
||||
// Drop MCP entries whose remote transport the runtime didn't advertise.
|
||||
// See hermes.go for why sending an unsupported transport tanks session/new.
|
||||
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "grok", b.cfg.Logger)
|
||||
|
||||
cwd := opts.Cwd
|
||||
if cwd == "" {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
if opts.ResumeSessionID != "" {
|
||||
result, err := c.request(runCtx, "session/load", map[string]any{
|
||||
"cwd": cwd,
|
||||
"sessionId": opts.ResumeSessionID,
|
||||
"mcpServers": mcpServers,
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok session/load failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
var changed bool
|
||||
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result)
|
||||
if changed {
|
||||
b.cfg.Logger.Warn("agent returned a different session id on resume — original was likely lost; continuing with the new id",
|
||||
"backend", "grok",
|
||||
"requested", opts.ResumeSessionID,
|
||||
"actual", sessionID,
|
||||
)
|
||||
}
|
||||
if effectiveModel == "" {
|
||||
effectiveModel = extractACPCurrentModelID(result)
|
||||
}
|
||||
} else {
|
||||
result, err := c.request(runCtx, "session/new", map[string]any{
|
||||
"cwd": cwd,
|
||||
"mcpServers": mcpServers,
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok session/new failed: %v", err)
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
sessionID = extractACPSessionID(result)
|
||||
if sessionID == "" {
|
||||
finalStatus = "failed"
|
||||
finalError = "grok session/new returned no session ID"
|
||||
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
|
||||
return
|
||||
}
|
||||
if effectiveModel == "" {
|
||||
effectiveModel = extractACPCurrentModelID(result)
|
||||
}
|
||||
}
|
||||
|
||||
c.sessionID = sessionID
|
||||
// Early session pin so a cancelled run still preserves resume pointer.
|
||||
msgStream.send(Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
||||
b.cfg.Logger.Info("grok session created", "session_id", sessionID)
|
||||
|
||||
if opts.Model != "" {
|
||||
if _, err := c.request(runCtx, "session/set_model", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"modelId": opts.Model,
|
||||
}); err != nil {
|
||||
b.cfg.Logger.Warn("grok set_session_model failed", "error", err, "requested_model", opts.Model)
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok could not switch to model %q: %v", opts.Model, err)
|
||||
if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
|
||||
b.cfg.Logger.Warn("resumed session not found at set_model time; clearing session id so the daemon retries fresh",
|
||||
"backend", "grok",
|
||||
"session_id", sessionID,
|
||||
)
|
||||
sessionID = ""
|
||||
}
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Error: finalError,
|
||||
DurationMs: time.Since(startTime).Milliseconds(),
|
||||
SessionID: sessionID,
|
||||
}
|
||||
return
|
||||
}
|
||||
b.cfg.Logger.Info("grok session model set", "model", opts.Model)
|
||||
}
|
||||
|
||||
userText := prompt
|
||||
if opts.SystemPrompt != "" {
|
||||
// Grok also reads AGENTS.md from cwd; inline system prompt covers
|
||||
// Multica runtime brief delivery when file injection is not enough.
|
||||
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
|
||||
}
|
||||
|
||||
streamingCurrentTurn.Store(true)
|
||||
_, err = c.request(runCtx, "session/prompt", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"prompt": []map[string]any{
|
||||
{"type": "text", "text": userText},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("grok timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
} else {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("grok session/prompt failed: %v", err)
|
||||
if opts.ResumeSessionID != "" && isACPSessionNotFound(err) {
|
||||
b.cfg.Logger.Warn("resumed session not found at prompt time; clearing session id so the daemon retries fresh",
|
||||
"backend", "grok",
|
||||
"session_id", sessionID,
|
||||
)
|
||||
sessionID = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case pr := <-promptDone:
|
||||
if pr.stopReason == "cancelled" {
|
||||
finalStatus = "aborted"
|
||||
finalError = "grok cancelled the prompt"
|
||||
}
|
||||
c.usageMu.Lock()
|
||||
c.usage.InputTokens += pr.usage.InputTokens
|
||||
c.usage.OutputTokens += pr.usage.OutputTokens
|
||||
c.usage.CacheReadTokens += pr.usage.CacheReadTokens
|
||||
c.usageMu.Unlock()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
b.cfg.Logger.Info("grok finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
stdin.Close()
|
||||
cancel()
|
||||
|
||||
// Grok ACP may keep the process — and the stdout/stderr pipes — open
|
||||
// briefly after session/prompt returns. Bound the drain.
|
||||
drainCtx, drainCancel := context.WithTimeout(context.Background(), grokReaderDrainGrace)
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-drainCtx.Done():
|
||||
}
|
||||
select {
|
||||
case <-stderrDone:
|
||||
case <-drainCtx.Done():
|
||||
}
|
||||
drainCancel()
|
||||
streamingCurrentTurn.Store(false)
|
||||
|
||||
outputMu.Lock()
|
||||
finalOutput := output.String()
|
||||
outputMu.Unlock()
|
||||
|
||||
// Promote completed→failed when stderr or the agent text stream show a
|
||||
// terminal upstream-LLM failure (auth / rate-limit / HTTP 4xx).
|
||||
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr)
|
||||
|
||||
c.usageMu.Lock()
|
||||
u := c.usage
|
||||
c.usageMu.Unlock()
|
||||
|
||||
var usageMap map[string]TokenUsage
|
||||
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
|
||||
model := effectiveModel
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
usageMap = map[string]TokenUsage{model: u}
|
||||
}
|
||||
|
||||
resCh <- Result{
|
||||
Status: finalStatus,
|
||||
Output: finalOutput,
|
||||
Error: finalError,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: sessionID,
|
||||
Usage: usageMap,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgStream.ch, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// Grok's ACP `authenticate` method ids (from `initialize`.authMethods).
|
||||
// `xai.api_key` consumes XAI_API_KEY from the environment; `cached_token`
|
||||
// reuses the credentials written by `grok login`.
|
||||
const (
|
||||
grokAuthMethodAPIKey = "xai.api_key"
|
||||
grokAuthMethodCachedToken = "cached_token"
|
||||
)
|
||||
|
||||
// selectGrokAuthMethod chooses which advertised ACP auth method to use,
|
||||
// following xAI's documented headless flow: prefer the API key when
|
||||
// XAI_API_KEY is present in the child env and the CLI offers it, otherwise
|
||||
// fall back to the cached login token. If the CLI advertises methods but
|
||||
// offers neither known id, we still return the first advertised one so a
|
||||
// future rename doesn't hard-break the handshake (the real CLI then surfaces
|
||||
// a clear error). Returns ("", false) when no methods are advertised, which
|
||||
// means the CLI needs no explicit authenticate step.
|
||||
func selectGrokAuthMethod(methods []string, haveAPIKey bool) (string, bool) {
|
||||
if len(methods) == 0 {
|
||||
return "", false
|
||||
}
|
||||
offered := make(map[string]bool, len(methods))
|
||||
for _, m := range methods {
|
||||
offered[m] = true
|
||||
}
|
||||
if haveAPIKey && offered[grokAuthMethodAPIKey] {
|
||||
return grokAuthMethodAPIKey, true
|
||||
}
|
||||
if offered[grokAuthMethodCachedToken] {
|
||||
return grokAuthMethodCachedToken, true
|
||||
}
|
||||
return methods[0], true
|
||||
}
|
||||
|
||||
// envHasNonEmpty reports whether an `os/exec`-style env slice
|
||||
// ("KEY=value" entries) contains key with a non-empty value. Later entries
|
||||
// win (matching how the OS resolves duplicate keys), so we scan from the end.
|
||||
func envHasNonEmpty(env []string, key string) bool {
|
||||
prefix := key + "="
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(env[i], prefix) {
|
||||
return strings.TrimSpace(env[i][len(prefix):]) != ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewReturnsGrokBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("grok", Config{ExecutablePath: "/nonexistent/grok"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(grok) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*grokBackend); !ok {
|
||||
t.Fatalf("expected *grokBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeGrokACPScript impersonates `grok agent --always-approve stdio` for unit
|
||||
// tests. Wire format mirrors other Multica ACP fakes (traecli/kimi): method
|
||||
// "session/update" with update.sessionUpdate discriminators, session/new
|
||||
// returning sessionId + models, session/prompt returning stopReason=end_turn.
|
||||
func fakeGrokACPScript() string {
|
||||
return `#!/bin/sh
|
||||
if [ -n "$GROK_ARGS_FILE" ]; then
|
||||
for arg in "$@"; do
|
||||
printf '%s\n' "$arg" >> "$GROK_ARGS_FILE"
|
||||
done
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
if [ -n "$GROK_REQUESTS_FILE" ]; then
|
||||
printf '%s\n' "$line" >> "$GROK_REQUESTS_FILE"
|
||||
fi
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"authMethods":[{"id":"cached_token","name":"Cached login"},{"id":"xai.api_key","name":"API key"}],"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true}}}}\n' "$id"
|
||||
;;
|
||||
*'"method":"authenticate"'*)
|
||||
if [ -n "$GROK_AUTH_FAIL" ]; then
|
||||
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32000,"message":"authentication required: run grok login"}}\n' "$id"
|
||||
exit 0
|
||||
fi
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
||||
;;
|
||||
*'"method":"session/new"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"availableModels":[{"modelId":"grok-4.5","name":"Grok 4.5","description":""},{"modelId":"grok-composer-2.5-fast","name":"Grok Composer 2.5 Fast","description":""}]}}}\n' "$id"
|
||||
;;
|
||||
*'"method":"session/load"'*)
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_loaded","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"history replay ignored"}}}}\n'
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
||||
;;
|
||||
*'"method":"session/set_model"'*)
|
||||
case "$line" in
|
||||
*bogus-model*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*'"method":"session/prompt"'*)
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking about it"}}}}\n'
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call","toolCallId":"tc-1","name":"Shell","status":"pending","parameters":{"command":"echo hi"}}}}\n'
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-1","status":"completed","name":"Shell","output":"hi\\n"}}}\n'
|
||||
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"pong"}}}}\n'
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`
|
||||
}
|
||||
|
||||
func TestGrokBackendStreamsAndCompletes(t *testing.T) {
|
||||
t.Parallel()
|
||||
fakePath := filepath.Join(t.TempDir(), "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "say pong", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var messages []Message
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for m := range session.Messages {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}()
|
||||
result := <-session.Result
|
||||
<-done
|
||||
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Output, "pong") {
|
||||
t.Fatalf("output = %q, want it to contain the assistant message 'pong'", result.Output)
|
||||
}
|
||||
if result.SessionID != "ses_new" {
|
||||
t.Fatalf("session id = %q, want ses_new", result.SessionID)
|
||||
}
|
||||
var sawText, sawToolUse bool
|
||||
for _, m := range messages {
|
||||
if m.Type == MessageText && strings.Contains(m.Content, "pong") {
|
||||
sawText = true
|
||||
}
|
||||
if m.Type == MessageToolUse && m.Tool == "terminal" {
|
||||
sawToolUse = true
|
||||
}
|
||||
}
|
||||
if !sawText {
|
||||
t.Error("expected a MessageText carrying the assistant 'pong'")
|
||||
}
|
||||
if !sawToolUse {
|
||||
t.Errorf("expected the Shell tool_call to normalize to 'terminal'; messages=%+v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokBlockedArgsFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
argsFile := filepath.Join(tempDir, "argv.txt")
|
||||
fakePath := filepath.Join(tempDir, "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{
|
||||
ExecutablePath: fakePath,
|
||||
Logger: slog.Default(),
|
||||
Env: map[string]string{"GROK_ARGS_FILE": argsFile},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "task", ExecOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
ThinkingLevel: "high",
|
||||
// Users must not strip ACP mode, disable auto-approve, or switch
|
||||
// into print/headless transports.
|
||||
CustomArgs: []string{"agent", "stdio", "--always-approve", "--yolo", "headless", "-p", "--output-format", "json", "--permission-mode", "default", "--model", "hijack", "--reasoning-effort", "low", "--effort", "low", "--rules", "extra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
<-session.Result
|
||||
|
||||
raw, err := os.ReadFile(argsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read args file: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
wantPrefix := []string{"--no-auto-update", "agent", "--always-approve", "--effort", "high", "stdio"}
|
||||
if len(lines) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines)
|
||||
}
|
||||
for i, want := range wantPrefix {
|
||||
if lines[i] != want {
|
||||
t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(lines, " ")
|
||||
for _, once := range []string{"--no-auto-update", "agent", "--always-approve", "stdio"} {
|
||||
if c := countTokens(lines, once); c != 1 {
|
||||
t.Errorf("expected exactly one %q, got %d (full: %q)", once, c, joined)
|
||||
}
|
||||
}
|
||||
for _, blocked := range []string{"headless", "-p", "--output-format", "json", "--permission-mode", "default", "--yolo", "hijack"} {
|
||||
for _, got := range lines {
|
||||
if got == blocked {
|
||||
t.Errorf("blocked custom arg %q survived filtering: %q", blocked, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Daemon-owned thinking must win over custom --effort/--reasoning-effort low.
|
||||
if strings.Count(joined, "--effort") != 1 || !strings.Contains(joined, "--effort high") {
|
||||
t.Errorf("expected single --effort high, got %q", joined)
|
||||
}
|
||||
if strings.Contains(joined, "--reasoning-effort") {
|
||||
t.Errorf("legacy --reasoning-effort must be stripped, got %q", joined)
|
||||
}
|
||||
// An allowed custom arg must survive (after the fixed prefix).
|
||||
if !strings.Contains(joined, "--rules") || !strings.Contains(joined, "extra") {
|
||||
t.Errorf("expected allowed custom arg --rules to survive, got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokSetModelFailureFailsTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
fakePath := filepath.Join(t.TempDir(), "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "task", ExecOptions{Model: "bogus-model", Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
result := <-session.Result
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("expected failed on set_model error, got %q", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) {
|
||||
t.Errorf("expected error to name the model, got %q", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Error, "model not available") {
|
||||
t.Errorf("expected upstream message surfaced, got %q", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokUsesSessionLoadForResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
requestsFile := filepath.Join(tempDir, "requests.jsonl")
|
||||
fakePath := filepath.Join(tempDir, "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{
|
||||
ExecutablePath: fakePath,
|
||||
Logger: slog.Default(),
|
||||
Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "continue", ExecOptions{
|
||||
ResumeSessionID: "ses_existing",
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
result := <-session.Result
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error)
|
||||
}
|
||||
if result.SessionID != "ses_existing" {
|
||||
t.Fatalf("session id = %q, want ses_existing", result.SessionID)
|
||||
}
|
||||
raw, err := os.ReadFile(requestsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read requests: %v", err)
|
||||
}
|
||||
requests := string(raw)
|
||||
if !strings.Contains(requests, `"method":"session/load"`) {
|
||||
t.Fatalf("expected session/load on resume, got:\n%s", requests)
|
||||
}
|
||||
if strings.Contains(requests, `"method":"session/resume"`) {
|
||||
t.Fatalf("grok must use session/load when resuming, not session/resume:\n%s", requests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrokAuthenticatesBeforeSession asserts the ACP auth handshake happens in
|
||||
// the order the real Grok CLI requires: `authenticate` must be sent after
|
||||
// `initialize` and before any session operation (session/new or session/load).
|
||||
// A fake ACP that blindly accepts session/new (as ours does) would otherwise
|
||||
// hide a missing handshake — the exact gap this guards against.
|
||||
func TestGrokAuthenticatesBeforeSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resume string
|
||||
wantSession string
|
||||
}{
|
||||
{name: "new session", resume: "", wantSession: `"method":"session/new"`},
|
||||
{name: "resume", resume: "ses_existing", wantSession: `"method":"session/load"`},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
requestsFile := filepath.Join(tempDir, "requests.jsonl")
|
||||
fakePath := filepath.Join(tempDir, "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{
|
||||
ExecutablePath: fakePath,
|
||||
Logger: slog.Default(),
|
||||
Env: map[string]string{"GROK_REQUESTS_FILE": requestsFile},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "task", ExecOptions{
|
||||
ResumeSessionID: tc.resume,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
<-session.Result
|
||||
|
||||
raw, err := os.ReadFile(requestsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read requests: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
authIdx, sessionIdx, initIdx := -1, -1, -1
|
||||
for i, l := range lines {
|
||||
switch {
|
||||
case strings.Contains(l, `"method":"initialize"`):
|
||||
initIdx = i
|
||||
case strings.Contains(l, `"method":"authenticate"`):
|
||||
authIdx = i
|
||||
case strings.Contains(l, tc.wantSession):
|
||||
if sessionIdx == -1 {
|
||||
sessionIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if authIdx == -1 {
|
||||
t.Fatalf("expected an authenticate request, got:\n%s", raw)
|
||||
}
|
||||
if sessionIdx == -1 {
|
||||
t.Fatalf("expected a %s request, got:\n%s", tc.wantSession, raw)
|
||||
}
|
||||
if !(initIdx < authIdx && authIdx < sessionIdx) {
|
||||
t.Fatalf("expected order initialize(%d) < authenticate(%d) < session(%d):\n%s",
|
||||
initIdx, authIdx, sessionIdx, raw)
|
||||
}
|
||||
// The daemon has no XAI_API_KEY here, so it must fall back to the
|
||||
// cached-token method advertised by the fake.
|
||||
if !strings.Contains(lines[authIdx], `"methodId":"cached_token"`) {
|
||||
t.Errorf("expected cached_token auth method, got: %s", lines[authIdx])
|
||||
}
|
||||
if !strings.Contains(lines[authIdx], `"headless":true`) {
|
||||
t.Errorf("expected headless meta on authenticate, got: %s", lines[authIdx])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrokAuthFailureFailsTask asserts a rejected authenticate handshake fails
|
||||
// the task with a clear error instead of falling through to session/new.
|
||||
func TestGrokAuthFailureFailsTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
fakePath := filepath.Join(t.TempDir(), "grok")
|
||||
writeTestExecutable(t, fakePath, []byte(fakeGrokACPScript()))
|
||||
|
||||
backend, err := New("grok", Config{
|
||||
ExecutablePath: fakePath,
|
||||
Logger: slog.Default(),
|
||||
Env: map[string]string{"GROK_AUTH_FAIL": "1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "task", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
result := <-session.Result
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("expected failed on authenticate error, got %q", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Error, "authenticate") {
|
||||
t.Errorf("expected error to mention authenticate, got %q", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrokSelectAuthMethod covers the auth-method selection preference.
|
||||
func TestGrokSelectAuthMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
methods []string
|
||||
haveKey bool
|
||||
wantID string
|
||||
wantOK bool
|
||||
}{
|
||||
{"none advertised", nil, false, "", false},
|
||||
{"cached only", []string{"cached_token"}, false, "cached_token", true},
|
||||
{"api key preferred when present", []string{"cached_token", "xai.api_key"}, true, "xai.api_key", true},
|
||||
{"api key ignored without env", []string{"cached_token", "xai.api_key"}, false, "cached_token", true},
|
||||
{"unknown falls back to first", []string{"future_method"}, true, "future_method", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := selectGrokAuthMethod(tc.methods, tc.haveKey)
|
||||
if got != tc.wantID || ok != tc.wantOK {
|
||||
t.Errorf("%s: selectGrokAuthMethod(%v, %v) = (%q, %v), want (%q, %v)",
|
||||
tc.name, tc.methods, tc.haveKey, got, ok, tc.wantID, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokIsKnownThinkingValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, level := range []string{"", "none", "minimal", "low", "medium", "high", "xhigh"} {
|
||||
if !IsKnownThinkingValue("grok", level) {
|
||||
t.Errorf("IsKnownThinkingValue(grok, %q) = false", level)
|
||||
}
|
||||
}
|
||||
// grok tops out at xhigh; "max" is not a grok --effort level, so it must
|
||||
// not pass the persist gate (it would otherwise be dropped at execution).
|
||||
for _, level := range []string{"bogus", "max"} {
|
||||
if IsKnownThinkingValue("grok", level) {
|
||||
t.Errorf("IsKnownThinkingValue(grok, %q) = true, want rejected", level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrokRealACPSmoke drives the REAL `grok agent stdio` binary end-to-end
|
||||
// when it is installed and authenticated. Skipped automatically when grok is
|
||||
// not on PATH or the session cannot be created, so CI stays green.
|
||||
func TestGrokRealACPSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping real-binary smoke test in -short mode")
|
||||
}
|
||||
path, err := exec.LookPath("grok")
|
||||
if err != nil {
|
||||
t.Skip("grok not on PATH; skipping real-binary smoke test")
|
||||
}
|
||||
|
||||
backend, err := New("grok", Config{ExecutablePath: path, Logger: slog.Default()})
|
||||
if err != nil {
|
||||
t.Fatalf("new grok backend: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{
|
||||
Cwd: t.TempDir(),
|
||||
Timeout: 80 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
go func() {
|
||||
for range session.Messages {
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-session.Result:
|
||||
if result.Status == "failed" && (strings.Contains(result.Error, "session/new") || strings.Contains(result.Error, "initialize")) {
|
||||
t.Skipf("grok not authenticated or ACP unavailable: %v", result.Error)
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("real grok run did not complete: status=%q error=%q", result.Status, result.Error)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(result.Output), "pong") {
|
||||
t.Fatalf("expected real grok output to contain 'pong', got %q", result.Output)
|
||||
}
|
||||
if result.SessionID == "" {
|
||||
t.Error("expected a non-empty session id from real grok")
|
||||
}
|
||||
t.Logf("real grok smoke OK: session=%s output=%q", result.SessionID, result.Output)
|
||||
case <-time.After(90 * time.Second):
|
||||
t.Fatal("timeout waiting for real grok result")
|
||||
}
|
||||
}
|
||||
@@ -1534,6 +1534,32 @@ func extractACPSessionID(result json.RawMessage) string {
|
||||
return r.SessionID
|
||||
}
|
||||
|
||||
// extractACPAuthMethods returns the `authMethods` ids advertised in an ACP
|
||||
// `initialize` response, in the order the agent listed them. Agents that
|
||||
// require authentication (e.g. xAI's Grok Build) enumerate the accepted
|
||||
// methods here; per the ACP flow the client MUST send `authenticate` with one
|
||||
// of these ids before `session/new` / `session/load`. Agents that need no
|
||||
// explicit auth omit the field, so an empty slice means "skip authenticate".
|
||||
// A malformed response degrades to an empty slice (fail open on parsing so we
|
||||
// don't wedge agents that never needed the step).
|
||||
func extractACPAuthMethods(result json.RawMessage) []string {
|
||||
var r struct {
|
||||
AuthMethods []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"authMethods"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &r); err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(r.AuthMethods))
|
||||
for _, m := range r.AuthMethods {
|
||||
if id := strings.TrimSpace(m.ID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// extractACPCurrentModelID pulls the model selected by the ACP runtime out of
|
||||
// a session/new or session/resume response. Hermes returns this when it uses
|
||||
// its own default model, so token usage can still be attributed to a real model
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -164,6 +165,13 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
|
||||
annotateCodebuddyThinking(ctx, models, executablePath)
|
||||
return models, nil
|
||||
})
|
||||
case "grok":
|
||||
// xAI Grok Build is ACP-native (`grok agent stdio`); model catalog
|
||||
// comes from session/new. Falls back to a small static list so the
|
||||
// UI picker stays usable offline / unauthenticated.
|
||||
return cachedDiscovery(providerType, func() ([]Model, error) {
|
||||
return discoverGrokModels(ctx, executablePath)
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q", providerType)
|
||||
}
|
||||
@@ -895,6 +903,12 @@ type acpDiscoveryProvider struct {
|
||||
// acpArgs is the argv passed to the binary to start it in ACP
|
||||
// server mode. Defaults to []string{"acp"} when nil/empty.
|
||||
acpArgs []string
|
||||
// authMethodID, when non-empty, inserts an ACP `authenticate` request
|
||||
// (with the given methodId) between `initialize` and `session/new`.
|
||||
// Agents that need no auth (kiro/qoder/trae) leave this empty and the
|
||||
// handshake is unchanged. Grok Build requires it or session/new is
|
||||
// rejected, degrading discovery to the static fallback catalog.
|
||||
authMethodID string
|
||||
}
|
||||
|
||||
// discoverACPModels runs the ACP handshake for any agent CLI that
|
||||
@@ -977,14 +991,29 @@ func discoverACPModels(ctx context.Context, executablePath string, p acpDiscover
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
if err := writeACP(2, "session/new", map[string]any{
|
||||
// Agents that gate session ops behind an auth handshake (Grok Build)
|
||||
// need `authenticate` before session/new; others leave authMethodID
|
||||
// empty and this step is skipped, keeping their id sequence unchanged.
|
||||
sessionNewID := 2
|
||||
if p.authMethodID != "" {
|
||||
if err := writeACP(2, "authenticate", map[string]any{
|
||||
"methodId": p.authMethodID,
|
||||
"_meta": map[string]any{"headless": true},
|
||||
}); err != nil {
|
||||
return []Model{}, nil
|
||||
}
|
||||
sessionNewID = 3
|
||||
}
|
||||
|
||||
if err := writeACP(sessionNewID, "session/new", map[string]any{
|
||||
"cwd": tmp,
|
||||
"mcpServers": []any{},
|
||||
}); err != nil {
|
||||
return []Model{}, nil
|
||||
}
|
||||
wantID := strconv.Itoa(sessionNewID)
|
||||
|
||||
// Read responses until we see the one for id=2 (session/new).
|
||||
// Read responses until we see the one for session/new.
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024)
|
||||
deadline := time.After(12 * time.Second)
|
||||
@@ -1003,7 +1032,7 @@ func discoverACPModels(ctx context.Context, executablePath string, p acpDiscover
|
||||
if err := json.Unmarshal([]byte(line), &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env.ID.String() != "2" || len(env.Result) == 0 {
|
||||
if env.ID.String() != wantID || len(env.Result) == 0 {
|
||||
continue
|
||||
}
|
||||
done <- parseACPSessionNewModels(env.Result)
|
||||
@@ -1157,6 +1186,73 @@ func parseAntigravityModels(output string) []Model {
|
||||
return models
|
||||
}
|
||||
|
||||
// discoverGrokModels spins up `grok agent --always-approve stdio` and parses
|
||||
// the model catalog from session/new (same shape as Kiro/Qoder/Trae). Requires
|
||||
// an authenticated Grok CLI; on any failure falls back to grokStaticModels.
|
||||
func discoverGrokModels(ctx context.Context, executablePath string) ([]Model, error) {
|
||||
// Match the daemon's runtime launch: `--no-auto-update` (global) so a
|
||||
// background update check can't stall discovery, and the same auth
|
||||
// handshake — prefer the API key when XAI_API_KEY is set, else the cached
|
||||
// login token — so session/new returns the real catalog instead of being
|
||||
// rejected (which silently falls back to grokStaticModels).
|
||||
authMethod := grokAuthMethodCachedToken
|
||||
if strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" {
|
||||
authMethod = grokAuthMethodAPIKey
|
||||
}
|
||||
models, err := discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
|
||||
defaultBin: "grok",
|
||||
clientName: "multica-model-discovery",
|
||||
tmpdirPrefix: "multica-grok-discovery-",
|
||||
acpArgs: []string{"--no-auto-update", "agent", "--always-approve", "stdio"},
|
||||
authMethodID: authMethod,
|
||||
})
|
||||
if err != nil || len(models) == 0 {
|
||||
return grokStaticModels(), nil
|
||||
}
|
||||
for i := range models {
|
||||
if models[i].Provider == "" {
|
||||
models[i].Provider = "xai"
|
||||
}
|
||||
}
|
||||
annotateGrokThinking(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// grokStaticModels is the offline fallback catalog for the Grok Build CLI.
|
||||
// IDs match a typical signed-in `session/new` / `grok models` listing.
|
||||
func grokStaticModels() []Model {
|
||||
models := []Model{
|
||||
{ID: "grok-4.5", Label: "Grok 4.5", Provider: "xai", Default: true},
|
||||
{ID: "grok-composer-2.5-fast", Label: "Grok Composer 2.5 Fast", Provider: "xai"},
|
||||
}
|
||||
annotateGrokThinking(models)
|
||||
return models
|
||||
}
|
||||
|
||||
// annotateGrokThinking attaches the fixed --effort vocabulary to
|
||||
// every discovered Grok model. Per-model capability is not exposed by
|
||||
// session/new, so the full canonical set is offered and the CLI rejects
|
||||
// unsupported levels at runtime if a model cannot reason. grok tops out at
|
||||
// `xhigh`; this list must stay in lockstep with the grok entry in
|
||||
// providerThinkingEnums (thinking.go) so a persisted level is never silently
|
||||
// dropped at execution by ValidateThinkingLevel.
|
||||
func annotateGrokThinking(models []Model) {
|
||||
levels := []ThinkingLevel{
|
||||
{Value: "none", Label: "None"},
|
||||
{Value: "minimal", Label: "Minimal"},
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High"},
|
||||
{Value: "xhigh", Label: "Extra high"},
|
||||
}
|
||||
for i := range models {
|
||||
models[i].Thinking = &ModelThinking{
|
||||
DefaultLevel: "",
|
||||
SupportedLevels: levels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverCursorModels runs `cursor-agent --list-models` and parses
|
||||
// the `id - Label` rows. Cursor's catalog changes often and ships
|
||||
// many variants of the same base model (thinking / fast / max
|
||||
|
||||
@@ -666,6 +666,18 @@ var providerThinkingEnums = map[string]map[string]bool{
|
||||
"high": true,
|
||||
"xhigh": true,
|
||||
},
|
||||
// Grok Build CLI --effort canonical levels (see Grok headless docs).
|
||||
// grok tops out at `xhigh`; keep this in lockstep with
|
||||
// annotateGrokThinking's SupportedLevels so a persisted level is never
|
||||
// silently dropped at execution by ValidateThinkingLevel.
|
||||
"grok": {
|
||||
"none": true,
|
||||
"minimal": true,
|
||||
"low": true,
|
||||
"medium": true,
|
||||
"high": true,
|
||||
"xhigh": true,
|
||||
},
|
||||
}
|
||||
|
||||
// IsKnownThinkingValue reports whether `value` is a recognised effort
|
||||
|
||||
@@ -450,6 +450,12 @@ func TestIsKnownThinkingValue(t *testing.T) {
|
||||
{"opencode", "bad value", false}, // spaces are not valid variant names
|
||||
{"hermes", "", true},
|
||||
{"hermes", "low", false}, // hermes has no thinking concept
|
||||
{"grok", "", true},
|
||||
{"grok", "none", true},
|
||||
{"grok", "low", true},
|
||||
{"grok", "xhigh", true},
|
||||
{"grok", "max", false}, // grok tops out at xhigh; max is not a grok level
|
||||
{"grok", "bogus", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := IsKnownThinkingValue(tc.provider, tc.value); got != tc.want {
|
||||
|
||||
@@ -14,6 +14,7 @@ var MinVersions = map[string]string{
|
||||
"claude": "2.0.0",
|
||||
"codex": "0.100.0", // app-server --listen stdio:// added in 0.100.0
|
||||
"copilot": "1.0.0", // --output-format json envelope stable from 1.0.x
|
||||
"grok": "0.2.89", // ACP + authenticate/session-load/set_model/MCP and --effort thinking flag
|
||||
}
|
||||
|
||||
// MinQuickCreateCLIVersion gates the agent-create (quick-create) flow against
|
||||
|
||||
@@ -155,6 +155,10 @@ func TestCheckMinVersion(t *testing.T) {
|
||||
{"codex", "codex-cli 0.100.0", false},
|
||||
{"codex", "codex-cli 0.99.0", true},
|
||||
{"codex", "codex-cli 0.50.0", true},
|
||||
{"grok", "grok 0.2.93 (f00f96316d4b) [stable]", false},
|
||||
{"grok", "0.2.89", false},
|
||||
{"grok", "0.2.0", true},
|
||||
{"grok", "0.1.9", true},
|
||||
{"unknown", "1.0.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user