From f127553b95cfe04678561a762799592a86110701 Mon Sep 17 00:00:00 2001 From: xy3 <120182408@qq.com> Date: Sat, 24 Oct 2026 11:51:55 +0800 Subject: [PATCH 01/11] feat: add native MiniMax Code ACP agent support --- README.md | 3 +- docs/agents/minimax-code.md | 71 +++++++++++ docs/readme/README.ar.md | 3 +- docs/readme/README.de.md | 3 +- docs/readme/README.es.md | 3 +- docs/readme/README.fr.md | 3 +- docs/readme/README.ja.md | 3 +- docs/readme/README.ko.md | 3 +- docs/readme/README.pt.md | 3 +- docs/readme/README.zh-CN.md | 3 +- docs/readme/README.zh-TW.md | 3 +- src-tauri/src/acp/connection.rs | 60 +++++++++ src-tauri/src/acp/file_system_runtime.rs | 6 +- src-tauri/src/acp/minimax_code.rs | 31 +++++ src-tauri/src/acp/mod.rs | 1 + src-tauri/src/acp/registry.rs | 91 +++++++++++++- src-tauri/src/commands/acp.rs | 118 +++++++++++++++--- src-tauri/src/commands/mcp.rs | 93 +++++++++++++- .../src/db/service/agent_setting_service.rs | 1 + src-tauri/src/db/service/import_service.rs | 4 +- src-tauri/src/models/agent.rs | 34 ++++- src-tauri/src/parsers/mod.rs | 8 +- src/components/agent-icon.tsx | 20 +++ .../chat/composer/reference-uri.test.ts | 17 +++ .../settings/acp-agent-settings.test.tsx | 18 +++ .../settings/acp-agent-settings.tsx | 8 ++ src/components/settings/mcp-settings.tsx | 2 + .../minimax-code-config-panel.test.tsx | 35 ++++++ .../settings/minimax-code-config-panel.tsx | 31 +++++ src/i18n/messages/ar.json | 7 ++ src/i18n/messages/de.json | 7 ++ src/i18n/messages/en.json | 7 ++ src/i18n/messages/es.json | 7 ++ src/i18n/messages/fr.json | 7 ++ src/i18n/messages/ja.json | 7 ++ src/i18n/messages/ko.json | 7 ++ src/i18n/messages/pt.json | 7 ++ src/i18n/messages/zh-CN.json | 7 ++ src/i18n/messages/zh-TW.json | 7 ++ src/lib/minimax-code.test.ts | 27 ++++ src/lib/session-settings.test.ts | 19 +++ src/lib/types.ts | 8 +- 42 files changed, 755 insertions(+), 48 deletions(-) create mode 100644 docs/agents/minimax-code.md create mode 100644 src-tauri/src/acp/minimax_code.rs create mode 100644 src/components/settings/minimax-code-config-panel.test.tsx create mode 100644 src/components/settings/minimax-code-config-panel.tsx create mode 100644 src/lib/minimax-code.test.ts diff --git a/README.md b/README.md index 3029fa0f35b..72a024a2a6e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ - + @@ -285,6 +285,7 @@ Codeg talks to every agent over the [Agent Client Protocol](https://agentclientp Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/agents/minimax-code.md b/docs/agents/minimax-code.md new file mode 100644 index 00000000000..617df4d2782 --- /dev/null +++ b/docs/agents/minimax-code.md @@ -0,0 +1,71 @@ +# MiniMax Code + +Codeg connects directly to [official MiniMax Code](https://github.com/MiniMax-AI/minimax-code) by running `mcode acp` over stdio. This is the native MiniMax Code agent, not MiniMax models routed through OpenCode or another adapter. + +## Setup + +Install MiniMax Code on the machine running Codeg's backend. For server deployments, installing it on the browser's machine is not sufficient. + +macOS, Linux, or WSL: + +```sh +curl -fsSL https://filecdn.minimax.chat/public/install.sh | bash +``` + +Windows PowerShell: + +```powershell +irm https://filecdn.minimax.chat/public/install.ps1 | iex +``` + +These are MiniMax's official installation commands. Review their scripts before running them. The installer prepares a compatible Node.js runtime when needed; its one-command Linux installer does not support Alpine/musl. See the [official installation guide](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/docs/installation.md) for alternatives and requirements. + +Reopen the terminal and restart Codeg if necessary so its backend inherits the updated PATH. Check `mcode --version` and `mcode --help`. Then sign in using the region of your account: + +```sh +# Mainland China account +mcode login + +# Global account +mcode login --region global +``` + +Complete sign-in in the browser. In MiniMax Code, `/status` checks the account and `/provider` selects a provider/model. A selected bring-your-own-key provider does not require a MiniMax login. Configure those providers through MiniMax Code's own `provider` commands or configuration, rather than generic OpenAI environment variables in Codeg. + +Select MiniMax Code in Codeg's Agent Settings and then in a workspace. Codeg uses the installed `mcode` command; it does not install a second ACP bridge or copy your credentials. Authentication failures should be resolved in MiniMax Code on the backend machine. + +## Configuration and models + +MiniMax Code's installation directory and user data directory are different. The official installer normally uses `~/.minimax-code` for installation files. Current upstream source and the documented npm 0.6.9 release use `~/.minimax` for user data, or `~/.minimax-` for a selected profile. A nonempty `MINIMAX_DATA_DIR` takes precedence over `MAVIS_DATA_DIR`. Keep the same environment as your working MiniMax Code installation to reuse its authentication and provider configuration. Defaults may differ across releases; consult the installation guide before moving or editing data. + +The native agent advertises model choices through ACP session configuration options. Codeg follows these advertised values rather than hardcoding MiniMax model names. Model option values encode the provider, model and optional variant and must be treated as opaque IDs. Reasoning-effort controls appear only when the selected model advertises them. The native agent handles model changes with `session/set_config_option`, not the legacy `session/set_model` method. + +Native session modes are Default and, when available, Plan. Permission mode is a separate configuration option: Ask (`default`), Auto (`auto`), or Full access (`bypassPermissions`). Upstream marks permission mode as process-scoped, so a change can affect multiple sessions attached to the same agent process. Permission prompts use ACP allow-once, optional always-allow, and deny choices. + +## Sessions and tools + +Codeg records its own ACP transcripts for conversations started in Codeg. The native agent advertises session loading, and reconnecting requires the original session ID and matching workspace directory. This integration does not import or scan MiniMax Code's native session database. Existing custom agent registrations remain separate from the built-in MiniMax Code entry. + +MiniMax Code reads native MCP servers from the active data directory's `mcp.json`, under a `mcpServers` object. It also supports workspace `.mcp.json` subject to native trust/configuration rules. ACP-provided servers are session-scoped overlays, not replacements for native configuration; same-name session entries take precedence. Codeg stores assigned MCP servers in `mcp/minimax_code.json` inside its own data directory and forwards them, together with its per-launch collaboration companion, as ACP session overlays. It does not modify MiniMax Code's native MCP, provider or credential files. + +Manage skills through MiniMax Code itself. Codeg-managed skill installation is not enabled for this integration; native profile selection and external skill roots remain governed by MiniMax Code's own configuration. + +## Attachments and current scope + +Attachment support follows the installed agent's negotiated ACP capabilities. Published MiniMax Code 0.6.13 advertises image support, but not audio or embedded context. File links remain references for native tools. An advertised capability does not guarantee that the selected provider/model supports that input. + +Upstream supports stdio, HTTP and SSE MCP transports; Codeg's supported configuration paths and transports are a separate integration concern. Native extensions, native history import, provider/credential editing, and desktop-app-only features are outside this integration. + +## Validation baseline + +An isolated, unauthenticated `initialize` smoke test passed with `@minimax-ai/code@0.6.13`. It confirmed ACP version 1, session loading, HTTP/SSE MCP, image capability, and session list/fork/resume/close declarations. No credentials or model requests were used. This verifies startup and capability negotiation only, not authenticated conversations, session restoration, tool execution, or end-to-end image processing. + +## Upstream references + +The protocol details above were inspected at upstream commit [`da22f11c55d4748155d7162c1030414228a00565`](https://github.com/MiniMax-AI/minimax-code/commit/da22f11c55d4748155d7162c1030414228a00565). Published CLI releases can differ from this source snapshot. + +- [CLI setup and installation](https://github.com/MiniMax-AI/minimax-code#quick-start) +- [Accounts and data directories](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/docs/installation.md#accounts-and-data) +- [ACP capability and session handlers](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/tui/src/acp/agent.ts) +- [Model, mode and permission configuration](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/tui/src/acp/control-state.ts) +- [Native MCP configuration and overlays](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/packages/local-runtime-v2/src/service/mcp/runtime/local-mcp.service.ts) diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 3de9375aff0..0bf1c68a5a7 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 الوكلاء المدعومون -يتواصل Codeg مع كل وكيل عبر [Agent Client Protocol](https://agentclientprotocol.com)، فيحصل كلٌّ منهم على الواجهة المنظَّمة نفسها. ويأتي بتسعة عشر وكيلاً مدمجًا، يتولّى تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك: +يتواصل Codeg مع كل وكيل عبر [Agent Client Protocol](https://agentclientprotocol.com)، فيحصل كلٌّ منهم على الواجهة المنظَّمة نفسها. ويأتي بعشرين وكيلاً مدمجًا، يتولّى تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك:

Claude Code   @@ -285,6 +285,7 @@ git diff | codeg-cli -a codex \ Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index bf4f4b0b42a..8c713c14a97 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -31,7 +31,7 @@

🧩 One interface for every agent
Nineteen agents built in, and any ACP agent can join — all rendered as the same structured conversation, not a terminal.
🧩 One interface for every agent
Twenty agents built in, and any ACP agent can join — all rendered as the same structured conversation, not a terminal.
🔎 Sessions that travel
Import, search, and resume the history every agent keeps on disk, then hand it to a different agent.
- + @@ -285,6 +285,7 @@ Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentc Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 16c4497b295..1e86badfbdb 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -31,7 +31,7 @@
🧩 Eine Oberfläche für alle Agenten
Neunzehn Agenten sind eingebaut, und jeder ACP-Agent kann dazukommen — alle dargestellt als dieselbe strukturierte Unterhaltung, nicht als Terminal.
🧩 Eine Oberfläche für alle Agenten
Zwanzig Agenten sind eingebaut, und jeder ACP-Agent kann dazukommen — alle dargestellt als dieselbe strukturierte Unterhaltung, nicht als Terminal.
🔎 Sitzungen zum Mitnehmen
Importiere, durchsuche und setze die Historie fort, die jeder Agent auf der Festplatte ablegt, und übergib sie dann an einen anderen Agenten.
- + @@ -285,6 +285,7 @@ Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://ag Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 8dbf55c38ea..b8af933574b 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -31,7 +31,7 @@
🧩 Una interfaz para todos los agentes
Diecinueve agentes integrados, y cualquier agente compatible con ACP puede sumarse: todos se presentan como la misma conversación estructurada, no como una terminal.
🧩 Una interfaz para todos los agentes
Veinte agentes integrados, y cualquier agente compatible con ACP puede sumarse: todos se presentan como la misma conversación estructurada, no como una terminal.
🔎 Sesiones que viajan
Importa, busca y retoma el historial que cada agente guarda en disco, y luego pásaselo a otro agente.
- + @@ -285,6 +285,7 @@ Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentcl Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 1b0e5039438..5837b48971b 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -31,7 +31,7 @@
🧩 Une interface pour tous les agents
Dix-neuf agents intégrés, et tout agent ACP peut s'y ajouter — tous affichés sous la même forme de conversation structurée, pas dans un terminal.
🧩 Une interface pour tous les agents
Vingt agents intégrés, et tout agent ACP peut s'y ajouter — tous affichés sous la même forme de conversation structurée, pas dans un terminal.
🔎 Des sessions qui voyagent
Importez, fouillez et reprenez l'historique que chaque agent conserve sur le disque, puis confiez-le à un autre agent.
- + @@ -285,6 +285,7 @@ Codeg はすべてのエージェントと [Agent Client Protocol](https://agent Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 48e05c46d11..cee889b467d 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -31,7 +31,7 @@
🧩 すべてのエージェントを、ひとつのインターフェースで
19 種のエージェントを内蔵し、ACP エージェントならどれでも加えられます — いずれもターミナルではなく、同じ構造化された会話として表示されます。
🧩 すべてのエージェントを、ひとつのインターフェースで
20 種のエージェントを内蔵し、ACP エージェントならどれでも加えられます — いずれもターミナルではなく、同じ構造化された会話として表示されます。
🔎 引き継げるセッション
各エージェントがディスクに残す履歴をインポート・検索・再開でき、さらに別のエージェントへ引き継げます。
- + @@ -285,6 +285,7 @@ Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientproto Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index e2bd13738b2..bc687e9a3e2 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -31,7 +31,7 @@
🧩 모든 에이전트를 하나의 인터페이스로
열아홉 개의 에이전트가 기본 내장되어 있고, ACP 에이전트라면 무엇이든 합류할 수 있습니다 — 모두 터미널이 아닌, 같은 구조화된 대화로 표시됩니다.
🧩 모든 에이전트를 하나의 인터페이스로
스무 개의 에이전트가 기본 내장되어 있고, ACP 에이전트라면 무엇이든 합류할 수 있습니다 — 모두 터미널이 아닌, 같은 구조화된 대화로 표시됩니다.
🔎 에이전트를 넘나드는 세션
에이전트마다 디스크에 남겨 두는 기록을 가져와 검색하고 이어서 진행한 다음, 다른 에이전트에게 넘겨주세요.
- + @@ -285,6 +285,7 @@ O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agent Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 06d85bfe69f..c97c4701a30 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -31,7 +31,7 @@
🧩 Uma interface para todos os agentes
Dezenove agentes integrados, e qualquer agente compatível com ACP pode entrar — todos exibidos como a mesma conversa estruturada, e não como um terminal.
🧩 Uma interface para todos os agentes
Vinte agentes integrados, e qualquer agente compatível com ACP pode entrar — todos exibidos como a mesma conversa estruturada, e não como um terminal.
🔎 Sessões que viajam
Importe, pesquise e retome o histórico que cada agente guarda em disco e depois entregue-o a outro agente.
- + @@ -285,6 +285,7 @@ Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个 Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 6e608b69df8..f866a082cc2 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -31,7 +31,7 @@
🧩 一个界面,容纳所有智能体
内置十九个智能体,任何兼容 ACP 的智能体都能加入——全部呈现为同一套结构化对话,而不是一个终端。
🧩 一个界面,容纳所有智能体
内置二十个智能体,任何兼容 ACP 的智能体都能加入——全部呈现为同一套结构化对话,而不是一个终端。
🔎 会话随你流转
导入、搜索、续接每个智能体留在磁盘上的历史,还能把它交给另一个智能体接着做。
- + @@ -285,6 +285,7 @@ Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個 Hermes Agent   CodeBuddy   Kimi Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d6bb5b44afe..0111b05c5fb 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -31343,6 +31343,25 @@ mod tests { assert!(config_option_rejection(&toggle, "auto_approve", "true").is_none()); } + #[test] + fn minimax_code_model_config_preserves_opaque_native_ids() { + let model = "m:minimax:MiniMax-M2.5:u"; + let options: Vec = serde_json::from_value(serde_json::json!([{ + "id": "model", "name": "Model", "category": "model", "type": "select", + "currentValue": model, + "options": [{"value": model, "name": "MiniMax M2.5"}] + }])) + .unwrap(); + let mapped = map_session_config_options(&options); + assert!(is_model_config_option(&options[0])); + assert!(config_option_already_holds(&options[0], model)); + assert_eq!(current_model_id_from_opts(&mapped).as_deref(), Some(model)); + assert_eq!( + serde_json::to_value(encode_config_option_value(false, model)).unwrap(), + serde_json::json!({"value": model}) + ); + } + #[test] fn config_option_rejection_reads_a_grouped_select() { // Grouped options are flattened by `map_session_config_options` before the @@ -42507,6 +42526,47 @@ mod wire_mcp_gate_tests { assert!(load_mcp_servers_for_agent(AgentType::Trae).is_empty()); } + #[test] + fn minimax_code_forwards_client_mcp_and_keeps_delegation_on_the_wire() { + assert!(agent_delivers_wire_mcp(AgentType::MiniMaxCode)); + assert!(!agent_reads_own_mcp_config(AgentType::MiniMaxCode)); + let dir = tempfile::tempdir().unwrap(); + temp_env::with_var("CODEG_HOME", Some(dir.path()), || { + let path = crate::acp::minimax_code::mcp_json_path(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + serde_json::json!({"mcpServers": { + "stdio": {"command": "test-tool", "args": ["serve"]}, + "http": {"type": "http", "url": "https://example.test/mcp"}, + "sse": {"type": "sse", "url": "https://example.test/events"} + }}) + .to_string(), + ) + .unwrap(); + let servers = + serde_json::to_value(load_mcp_servers_for_agent(AgentType::MiniMaxCode)).unwrap(); + let entries = servers.as_array().unwrap(); + assert_eq!(entries.len(), 3); + assert!(entries + .iter() + .any(|server| server["name"] == "stdio" && server["command"] == "test-tool")); + for transport in ["http", "sse"] { + assert!(entries + .iter() + .any(|server| server["name"] == transport && server["type"] == transport)); + } + }); + assert_eq!( + transcript_dir_for(AgentType::MiniMaxCode), + Some("codeg-minimax-code") + ); + assert_ne!( + transcript_dir_for(AgentType::MiniMaxCode), + transcript_dir_for(AgentType::Custom("mcode")) + ); + } + // Command Code also starts both its `~/.commandcode/mcp.json` servers and // the ones on `session/new`: the file's stay off the wire, the companion // rides it. diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index cfd9accceba..a7407a0dabe 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -790,7 +790,11 @@ fn agent_root_slots(agent_type: AgentType) -> &'static [RootSlot] { // Command Code is the same case: codeg's transcript is its history, // and its tools never go through `fs/*` (verified against 1.84.0 and // 1.86.3). - AgentType::Custom(_) | AgentType::ZCode | AgentType::Trae | AgentType::CommandCode => &[], + AgentType::Custom(_) + | AgentType::ZCode + | AgentType::Trae + | AgentType::CommandCode + | AgentType::MiniMaxCode => &[], } } diff --git a/src-tauri/src/acp/minimax_code.rs b/src-tauri/src/acp/minimax_code.rs new file mode 100644 index 00000000000..02fa19eb0a5 --- /dev/null +++ b/src-tauri/src/acp/minimax_code.rs @@ -0,0 +1,31 @@ +//! MiniMax Code's client-owned MCP overlay. Native login, provider config, +//! skills and session state remain owned by `mcode` and are never rewritten. + +use std::path::PathBuf; + +/// Codeg's MCP assignments, forwarded through ACP session/new/load. Keeping +/// them outside the CLI data directory also supports per-agent data-dir env +/// overrides without writing into the wrong native profile. +pub(crate) fn mcp_json_path() -> PathBuf { + crate::paths::codeg_acp_transcripts_root() + .with_file_name("mcp") + .join("minimax_code.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimax_mcp_overlay_is_independent_of_native_profile_overrides() { + let expected = mcp_json_path(); + assert!(expected.ends_with("mcp/minimax_code.json")); + temp_env::with_vars( + [ + ("MINIMAX_DATA_DIR", Some("/native/primary")), + ("MAVIS_DATA_DIR", Some("/native/legacy")), + ], + || assert_eq!(mcp_json_path(), expected), + ); + } +} diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index dd621299557..506d952d605 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -30,6 +30,7 @@ pub mod js_text; pub mod latest_release; pub mod lifecycle; pub mod manager; +pub mod minimax_code; pub mod one_shot; pub mod opencode_catalog; pub mod opencode_launch; diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 4286aa838de..f43027cb34f 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -256,7 +256,7 @@ pub fn current_platform() -> &'static str { } } -/// The nineteen built-in agents. Excludes user-registered custom agents — use +/// The twenty built-in agents. Excludes user-registered custom agents — use /// [`all_acp_agents`] for the live set. pub fn builtin_acp_agents() -> Vec { vec![ @@ -279,10 +279,11 @@ pub fn builtin_acp_agents() -> Vec { AgentType::ZCode, AgentType::Trae, AgentType::CommandCode, + AgentType::MiniMaxCode, ] } -/// Every agent codeg can currently drive: the nineteen built-ins followed by +/// Every agent codeg can currently drive: the twenty built-ins followed by /// the user's registered custom ACP agents (sorted by id). pub fn all_acp_agents() -> Vec { let mut agents = builtin_acp_agents(); @@ -321,6 +322,8 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str { // a hand-registered custom Command Code most likely sits under the // package or bin name (`command-code`, `commandcode`, `cmd`, …). AgentType::CommandCode => "codeg-command-code", + // Preserve pre-existing hand-registered mcode / minimax-code agents. + AgentType::MiniMaxCode => "codeg-minimax-code", // A custom agent's registry id IS its identity. AgentType::Custom(id) => id, } @@ -347,6 +350,7 @@ pub fn from_registry_id(id: &str) -> Option { "codeg-zcode-acp" => Some(AgentType::ZCode), "codeg-trae-cli" => Some(AgentType::Trae), "codeg-command-code" => Some(AgentType::CommandCode), + "codeg-minimax-code" => Some(AgentType::MiniMaxCode), // Only ids the user has actually registered resolve. An unregistered // id must stay `None` so the ACP-registry picker still offers it as // "addable" rather than treating it as already supported. @@ -437,6 +441,10 @@ pub fn acp_adapter_relation(agent_type: AgentType) -> Option pub fn binary_system_dirs(agent_type: AgentType) -> &'static [&'static str] { match agent_type { AgentType::OpenCode => &[".opencode/bin"], + #[cfg(not(windows))] + AgentType::MiniMaxCode => &[".minimax-code/bin"], + #[cfg(windows)] + AgentType::MiniMaxCode => &[".minimax-code"], _ => &[], } } @@ -5620,8 +5628,29 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { node_required: Some("22.0.0"), }, }, - // Handled by the early return above; kept so the match stays - // exhaustive without a catch-all that could swallow a new built-in. + AgentType::MiniMaxCode => AcpAgentMeta { + agent_type, + supports_mcp: true, + name: "MiniMax Code", + description: "MiniMax's terminal coding agent (native ACP via mcode acp)", + // Launch the user's installation, retaining its native login and + // config. No wrapper, copied credentials or managed binary cache. + // Models/modes, streaming and permission requests use the generic + // ACP pipeline. Models are advertised through configOptions, not + // a hard-coded catalogue or the legacy session/set_model method. + // History is the Codeg-owned ACP transcript; native CLI history + // is deliberately not scanned without a stable import contract. + distribution: AgentDistribution::System { + cmd: "mcode", + args: &["acp"], + env: &[], + install_url: "https://github.com/MiniMax-AI/minimax-code#quick-start", + // Leave upgrades to the official CLI installer until an + // unattended updater contract is verified. + update_args: None, + }, + }, + // Handled by the early return above; keep this match exhaustive. AgentType::Custom(_) => unreachable!("custom agents resolve via custom_registry"), } } @@ -5881,10 +5910,13 @@ mod tests { } other => panic!("expected system distribution for Kiro, got {other:?}"), } - // Only Kiro and TRAE CLI ride the system distribution: every other + // Kiro, TRAE CLI and MiniMax Code use the system distribution; every other // built-in has a pinned version codeg installs itself. for agent_type in builtin_acp_agents() { - if !matches!(agent_type, AgentType::Kiro | AgentType::Trae) { + if !matches!( + agent_type, + AgentType::Kiro | AgentType::Trae | AgentType::MiniMaxCode + ) { assert!( get_agent_meta(agent_type).registry_version().is_some(), "{agent_type:?} lost its pin" @@ -5897,6 +5929,53 @@ mod tests { // up, so there is no pin, the launch is the user's `traecli acp serve`, // and Upgrade runs the CLI's own `traecli update`. Its registry id must // stay clear of every id a hand-registered custom TRAE entry could carry. + #[test] + fn minimax_code_launches_native_acp_without_overriding_configuration() { + let meta = get_agent_meta(AgentType::MiniMaxCode); + assert_eq!(meta.name, "MiniMax Code"); + assert!(meta.supports_mcp); + assert_eq!( + registry_id_for(AgentType::MiniMaxCode), + "codeg-minimax-code" + ); + assert_eq!( + from_registry_id("codeg-minimax-code"), + Some(AgentType::MiniMaxCode) + ); + assert!(meta.registry_version().is_none()); + assert!(!meta.supports_custom_version()); + assert!(acp_adapter_relation(AgentType::MiniMaxCode).is_none()); + match meta.distribution { + AgentDistribution::System { + cmd, + args, + env, + install_url, + update_args, + } => { + assert_eq!(cmd, "mcode"); + assert_eq!(args, &["acp"]); + assert!(env.is_empty()); + assert_eq!( + install_url, + "https://github.com/MiniMax-AI/minimax-code#quick-start" + ); + assert!(update_args.is_none()); + } + other => panic!("expected native system distribution, got {other:?}"), + } + #[cfg(not(windows))] + assert_eq!( + binary_system_dirs(AgentType::MiniMaxCode), + &[".minimax-code/bin"] + ); + #[cfg(windows)] + assert_eq!( + binary_system_dirs(AgentType::MiniMaxCode), + &[".minimax-code"] + ); + } + #[test] fn trae_launches_the_system_cli_without_a_pin() { let meta = get_agent_meta(AgentType::Trae); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 6264fe34e03..5d8fdae9494 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -239,12 +239,33 @@ pub(crate) fn resolve_system_agent_binary_for(agent_type: AgentType, cmd: &str) if let Some(path) = resolve_system_agent_binary(cmd) { return Some(path); } + resolve_system_agent_installer_binary(agent_type, cmd, &home_dir_or_default()) +} + +fn resolve_system_agent_installer_binary( + agent_type: AgentType, + cmd: &str, + home: &Path, +) -> Option { let exe = binary_cache::executable_file_name(cmd); - let home = home_dir_or_default(); - registry::binary_system_dirs(agent_type).iter().find_map(|dir| { - let cand = home.join(dir).join(&exe); - cand.is_file().then_some(cand) - }) + registry::binary_system_dirs(agent_type) + .iter() + .find_map(|dir| { + let cand = home.join(dir).join(&exe); + if cand.is_file() { + return Some(cand); + } + // The official Windows installer ships a cmd shim, not mcode.exe. + // PATH/PATHEXT handles it above; GUI launches also need this fallback. + #[cfg(windows)] + if agent_type == AgentType::MiniMaxCode { + let shim = home.join(dir).join("mcode.cmd"); + if shim.is_file() { + return Some(shim); + } + } + None + }) } /// Resolve the VENDOR CLI wrapped by an ACP adapter agent (`claude`, `codex` @@ -9515,6 +9536,9 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option None, // codeg cannot detect where an arbitrary ACP agent loads skills from, // so custom agents are gated on the user's own declaration: that the // agent reads the shared `.agents/skills` store (the cross-agent @@ -9528,7 +9552,6 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option None, AgentType::Custom(id) => { let decl = crate::acp::custom_registry::skills_decl(id); let mut global_dirs = Vec::new(); @@ -10869,6 +10892,13 @@ pub(crate) fn build_runtime_env_from_setting( merged.insert(key, trimmed.to_string()); } + // MiniMax Code uses its existing native login/provider configuration. + // Explicit env overrides remain available, but generic provider fields + // must not synthesize OPENAI_* or invented MiniMax variables. + if agent_type == AgentType::MiniMaxCode { + return merged; + } + let (api_base_url_key, api_key_key, model_key) = agent_env_keys(agent_type); if let Some(value) = trim_non_empty(config.api_base_url) { merged.insert(api_base_url_key.to_string(), value); @@ -10898,6 +10928,9 @@ pub(crate) async fn apply_model_provider_env( runtime_env: &mut BTreeMap, conn: &sea_orm::DatabaseConnection, ) { + if agent_type == AgentType::MiniMaxCode { + return; + } let provider_id = match setting.and_then(|s| s.model_provider_id) { Some(id) => id, None => return, @@ -11052,6 +11085,9 @@ fn cascade_update_agent_config( codex_model: &CodexModelAction, codex_model_raw: Option<&str>, ) -> Result<(), AcpError> { + if agent_type == AgentType::MiniMaxCode { + return Ok(()); + } let (url_key, key_key, _) = agent_env_keys(agent_type); match agent_type { AgentType::ClaudeCode | AgentType::Gemini => { @@ -11287,8 +11323,8 @@ fn cascade_update_agent_config( // `KIRO_API_KEY` in the agent's env) and accepts no custom base // URL, so it stays off the model-provider credential cascade. } - AgentType::ZCode => { - // Authentication and providers belong to the official ZCode runtime. + AgentType::ZCode | AgentType::MiniMaxCode => { + // Authentication and providers belong to the official agent runtime. } AgentType::Trae => { // TRAE CLI signs in to TRAE's own service (`traecli login`, or a @@ -11335,6 +11371,10 @@ pub(crate) async fn cascade_update_model_provider( Err(_) => continue, }; + if agent_type == AgentType::MiniMaxCode { + continue; + } + // 1. Update env_json in database (uses agent_env_keys for consistent key names) let (url_key, key_key, _) = agent_env_keys(agent_type); let mut env_map: BTreeMap = setting @@ -12270,16 +12310,18 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Result<(), AppCommand Ok(()) } +// MiniMax Code client MCP assignments belong to Codeg, not the native CLI +// profile. Forward this canonical stdio/http/sse store over ACP; the CLI also +// loads its own MCP config, and session entries win same-name conflicts. +fn read_minimax_code_servers() -> Result, AppCommandError> { + read_deepseek_servers_at(&crate::acp::minimax_code::mcp_json_path()) +} + fn read_deepseek_servers() -> Result, AppCommandError> { read_deepseek_servers_at(&deepseek_mcp_json_path()) } @@ -3144,7 +3154,7 @@ impl LocalMcpReader { } } -fn local_mcp_readers() -> [LocalMcpReader; 19] { +fn local_mcp_readers() -> [LocalMcpReader; 20] { [ LocalMcpReader::new("Claude Code", McpAppType::ClaudeCode, read_claude_servers), LocalMcpReader::new("Codex", McpAppType::Codex, read_codex_servers), @@ -3172,6 +3182,11 @@ fn local_mcp_readers() -> [LocalMcpReader; 19] { McpAppType::CommandCode, read_command_code_servers, ), + LocalMcpReader::new( + "MiniMax Code", + McpAppType::MiniMaxCode, + read_minimax_code_servers, + ), // Scan-only, so it reads last: the merge below is first-writer-wins, // and a pi entry that won would decide the transport every save of // that id is checked against — dropping an assignable agent that @@ -3336,6 +3351,9 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::ZCode => upsert_deepseek_server_at(&zcode_mcp_json_path(), id, spec), McpAppType::Trae => upsert_trae_server(id, spec), McpAppType::CommandCode => upsert_command_code_server(id, spec), + McpAppType::MiniMaxCode => { + upsert_deepseek_server_at(&crate::acp::minimax_code::mcp_json_path(), id, spec) + } } } @@ -3383,6 +3401,8 @@ pub fn read_servers_for_agent_type( // Command Code loads `~/.commandcode/mcp.json` natively — see the // Command Code section below for why it rides the forward skip list. AgentType::CommandCode => read_command_code_servers(), + // MiniMax Code receives client-owned assignments over the ACP wire. + AgentType::MiniMaxCode => read_minimax_code_servers(), // Custom agents get MCP purely over the ACP wire (`session/new`'s // `mcpServers`); codeg deliberately knows nothing about their native // config files, so there is no per-agent store to read back here. @@ -5369,6 +5389,9 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_deepseek_server_at(&zcode_mcp_json_path(), id), McpAppType::Trae => remove_trae_server(id), McpAppType::CommandCode => remove_command_code_server(id), + McpAppType::MiniMaxCode => { + remove_deepseek_server_at(&crate::acp::minimax_code::mcp_json_path(), id) + } } } @@ -8946,7 +8969,8 @@ mod tests { | McpAppType::Pi | McpAppType::ZCode | McpAppType::Trae - | McpAppType::CommandCode => {} + | McpAppType::CommandCode + | McpAppType::MiniMaxCode => {} } } @@ -9005,6 +9029,7 @@ mod tests { (McpAppType::ZCode, AgentType::ZCode), (McpAppType::Trae, AgentType::Trae), (McpAppType::CommandCode, AgentType::CommandCode), + (McpAppType::MiniMaxCode, AgentType::MiniMaxCode), ] { let wire = serde_json::to_value(app).expect("serialize app type"); assert_eq!( @@ -9020,6 +9045,68 @@ mod tests { } } + #[test] + fn minimax_code_mcp_overlay_round_trips_all_transports() { + use crate::models::agent::AgentType; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mcp/minimax_code.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let auth_path = dir.path().join("auth.json"); + fs::write(&auth_path, "leave native login untouched").unwrap(); + fs::write(&path, r#"{"other":{"keep":true},"mcpServers":{"existing":{"command":"existing","env":{"KEEP":"yes"}}}}"#).unwrap(); + temp_env::with_vars( + [ + ("CODEG_HOME", Some(dir.path())), + ("MINIMAX_DATA_DIR", Some(dir.path())), + ], + || { + assert_eq!(crate::acp::minimax_code::mcp_json_path(), path); + for (id, spec) in [ + ( + "stdio", + json!({"type":"stdio","command":"tool","args":["serve"],"env":{"TEST":"value"}}), + ), + ( + "http", + json!({"type":"http","url":"https://example.test/mcp","headers":{"X-Test":"value"}}), + ), + ( + "sse", + json!({"type":"sse","url":"https://example.test/events"}), + ), + ] { + assert!(app_can_host_spec(McpAppType::MiniMaxCode, &spec)); + upsert_server_for_app(McpAppType::MiniMaxCode, id, &spec).unwrap(); + let read = read_servers_for_agent_type(AgentType::MiniMaxCode).unwrap(); + assert_eq!(read[id]["type"], spec["type"]); + assert_eq!(read["existing"]["env"]["KEEP"], "yes"); + assert!(remove_server_for_app(McpAppType::MiniMaxCode, id).unwrap()); + assert!(!read_servers_for_agent_type(AgentType::MiniMaxCode) + .unwrap() + .contains_key(id)); + } + let saved: Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(saved["other"]["keep"], true); + assert_eq!(saved["mcpServers"].as_object().unwrap().len(), 1); + assert_eq!( + fs::read_to_string(&auth_path).unwrap(), + "leave native login untouched" + ); + fs::write(&path, "{broken").unwrap(); + assert!(upsert_server_for_app( + McpAppType::MiniMaxCode, + "new", + &json!({"command":"tool"}) + ) + .is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "{broken"); + assert!(!dir.path().join("mcp.json").exists()); + }, + ); + } + #[test] fn grok_config_toml_round_trips_and_preserves_sections() { // Grok reads `/config.toml` `[mcp_servers.]` natively — diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 017810e2aa6..5405353caeb 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -48,6 +48,7 @@ fn default_enabled(agent_type: AgentType) -> bool { | AgentType::ZCode | AgentType::Trae | AgentType::CommandCode + | AgentType::MiniMaxCode // A user who just registered a custom agent wants to use it. | AgentType::Custom(_) ) diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index b66a46b4318..af6286427cc 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -10,7 +10,7 @@ use crate::models::{AgentType, ConversationSummary, ImportResult}; use crate::parsers::{build_agent_parser, path_eq_for_matching, AgentEnv, AgentEnvs, AgentParser}; /// Every locally-parsable agent, in the canonical parser order. -const ALL_PARSER_AGENTS: [AgentType; 19] = [ +const ALL_PARSER_AGENTS: [AgentType; 20] = [ AgentType::ClaudeCode, AgentType::Codex, AgentType::OpenCode, @@ -34,6 +34,8 @@ const ALL_PARSER_AGENTS: [AgentType; 19] = [ // Codeg-owned ACP history only; Command Code's own session trees are not // read. AgentType::CommandCode, + // Codeg-owned ACP history only; no import of MiniMax Code CLI sessions. + AgentType::MiniMaxCode, ]; fn build_parser(agent_type: AgentType, env: &AgentEnv) -> Box { diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index 00a2537f7c1..c0a00face72 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -8,9 +8,9 @@ pub const CUSTOM_AGENT_WIRE_PREFIX: &str = "custom:"; /// Which agent backs a conversation. /// -/// The nineteen named variants are compile-time built-ins with hand-written +/// The twenty named variants are compile-time built-ins with hand-written /// launch metadata (`acp::registry`). Most use a dedicated transcript parser; -/// ZCode, TRAE CLI and Command Code use the Codeg-owned ACP transcript. +/// ZCode, TRAE CLI, Command Code and MiniMax Code use the Codeg-owned ACP transcript. /// [`AgentType::Custom`] is the open end: a user-registered /// ACP agent whose launch metadata lives in the database /// (`acp::custom_registry`) and whose history comes from codeg's own ACP @@ -41,12 +41,13 @@ pub enum AgentType { ZCode, Trae, CommandCode, + MiniMaxCode, /// A user-registered ACP agent, identified by its ACP-registry id /// (interned). Ordered last so built-ins keep their relative order. Custom(&'static str), } -/// The nineteen compile-time agents, in declaration order. Does NOT include +/// The twenty compile-time agents, in declaration order. Does NOT include /// custom agents — use [`crate::acp::registry::all_acp_agents`] for the live /// set that includes them. pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ @@ -69,6 +70,7 @@ pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ AgentType::ZCode, AgentType::Trae, AgentType::CommandCode, + AgentType::MiniMaxCode, ]; impl AgentType { @@ -81,7 +83,11 @@ impl AgentType { pub fn uses_acp_transcript(&self) -> bool { matches!( self, - AgentType::Custom(_) | AgentType::ZCode | AgentType::Trae | AgentType::CommandCode + AgentType::Custom(_) + | AgentType::ZCode + | AgentType::Trae + | AgentType::CommandCode + | AgentType::MiniMaxCode ) } @@ -128,6 +134,7 @@ impl AgentType { AgentType::ZCode => Cow::Borrowed("zcode"), AgentType::Trae => Cow::Borrowed("trae"), AgentType::CommandCode => Cow::Borrowed("command_code"), + AgentType::MiniMaxCode => Cow::Borrowed("minimax_code"), AgentType::Custom(id) => Cow::Owned(format!("{CUSTOM_AGENT_WIRE_PREFIX}{id}")), } } @@ -155,6 +162,7 @@ impl AgentType { "zcode" => Some(AgentType::ZCode), "trae" => Some(AgentType::Trae), "command_code" => Some(AgentType::CommandCode), + "minimax_code" => Some(AgentType::MiniMaxCode), other => other .strip_prefix(CUSTOM_AGENT_WIRE_PREFIX) .and_then(AgentType::custom), @@ -246,6 +254,7 @@ impl fmt::Display for AgentType { AgentType::ZCode => write!(f, "ZCode"), AgentType::Trae => write!(f, "TRAE CLI"), AgentType::CommandCode => write!(f, "Command Code"), + AgentType::MiniMaxCode => write!(f, "MiniMax Code"), // Prefer the registered display name; fall back to the raw id when // the registry has not been hydrated (or the agent was deleted // while conversations still reference it). @@ -285,12 +294,16 @@ mod tests { (AgentType::ZCode, "zcode"), (AgentType::Trae, "trae"), (AgentType::CommandCode, "command_code"), + (AgentType::MiniMaxCode, "minimax_code"), ]; for (agent, wire) in expected { assert_eq!(agent.as_wire(), wire); assert_eq!(AgentType::from_wire(wire), Some(agent)); // The JSON form is what `serde_json::to_string` writes to the DB. - assert_eq!(serde_json::to_string(&agent).unwrap(), format!("\"{wire}\"")); + assert_eq!( + serde_json::to_string(&agent).unwrap(), + format!("\"{wire}\"") + ); assert_eq!( serde_json::from_str::(&format!("\"{wire}\"")).unwrap(), agent @@ -393,6 +406,17 @@ mod tests { } } + #[test] + fn minimax_code_builtin_preserves_hand_registered_custom_identities() { + assert!(AgentType::MiniMaxCode.uses_acp_transcript()); + assert_eq!(AgentType::MiniMaxCode.to_string(), "MiniMax Code"); + for id in ["mcode", "minimax-code", "minimax_code"] { + let custom = AgentType::custom(id).expect("previously valid custom id"); + assert_eq!(AgentType::from_wire(&format!("custom:{id}")), Some(custom)); + assert_ne!(custom, AgentType::MiniMaxCode); + } + } + #[test] fn custom_id_rejects_path_traversal_and_builtin_shadowing() { for bad in [ diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 6557b5e4d90..d8d67b6fc00 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -423,9 +423,11 @@ pub fn build_agent_parser(agent_type: AgentType, env: &AgentEnv) -> Box { - Box::new(acp_native::AcpNativeParser::new(agent_type)) - } + AgentType::Custom(_) + | AgentType::ZCode + | AgentType::Trae + | AgentType::CommandCode + | AgentType::MiniMaxCode => Box::new(acp_native::AcpNativeParser::new(agent_type)), }; Box::new(RouteSanitized(inner)) } diff --git a/src/components/agent-icon.tsx b/src/components/agent-icon.tsx index 4fe28869194..b835df51fa7 100644 --- a/src/components/agent-icon.tsx +++ b/src/components/agent-icon.tsx @@ -585,6 +585,25 @@ const CommandCodeColorIcon = memo(function CommandCodeColorIcon({ ) }) +// A compact typographic mark, not a claim to reproduce MiniMax's wordmark. +const MiniMaxCodeColorIcon = memo(function MiniMaxCodeColorIcon({ + size = "1em", +}: IconProps) { + return ( + + MiniMax Code + + + + ) +}) + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyIcon = React.ComponentType @@ -594,6 +613,7 @@ const COLOR_ICONS: Partial> = { gemini: GeminiCliColorIcon, open_claw: OpenClawColorIcon, kimi_code: KimiCodeColorIcon, + minimax_code: MiniMaxCodeColorIcon, pi: PiColorIcon, deepseek: DeepSeekColorIcon, kiro: KiroColorIcon, diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts index cda6bb3685e..578caeb7f93 100644 --- a/src/components/chat/composer/reference-uri.test.ts +++ b/src/components/chat/composer/reference-uri.test.ts @@ -239,3 +239,20 @@ describe("an embedded reference uri that carries its ref", () => { }) }) }) + +it("preserves MiniMax Code agent and session references", () => { + expect( + parseCodegReferenceUri("codeg://agent/minimax_code", "@MiniMax Code") + ).toMatchObject({ + refType: "agent", + label: "MiniMax Code", + meta: { agentType: "minimax_code" }, + }) + expect( + parseCodegReferenceUri("codeg://session/minimax_code_session-123", "") + ).toMatchObject({ + refType: "session", + id: "minimax_code_session-123", + meta: { agentType: "minimax_code" }, + }) +}) diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index d502b307fe6..a4cc4c16397 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -2812,3 +2812,21 @@ describe("ZCode runtime configuration", () => { ).toBe("pass") }) }) + +describe("MiniMax Code native configuration", () => { + it("does not offer generic API fields or write keys to codeg settings", () => { + expect(importantEnvKeysByAgent("minimax_code")).toEqual({ + apiBaseUrl: [], + apiKey: [], + model: [], + }) + expect(importantFieldsFor("minimax_code")).toEqual({ + apiBaseUrl: false, + apiKey: false, + model: false, + }) + expect( + patchEnvByImportantKey("minimax_code", "KEEP=value", "apiKey", "secret") + ).toBe("KEEP=value") + }) +}) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index b2c8f4f77e3..40e0c19b63c 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -177,6 +177,7 @@ import { DeepSeekConfigPanel, } from "./deepseek-config-panel" import { DeepSeekModelListEditor } from "./deepseek-model-list-editor" +import { MiniMaxCodeConfigPanel } from "./minimax-code-config-panel" import { KimiCodeConfigPanel } from "./kimi-code-config-panel" import { PiConfigPanel } from "./pi-config-panel" import { QoderConfigPanel } from "./qoder-config-panel" @@ -931,6 +932,11 @@ export function importantEnvKeysByAgent( model: [], } } + if (agentType === "minimax_code") { + // Reuse mcode's own configuration and authentication. Do not offer + // generic OpenAI fields that the native CLI does not consume. + return { apiBaseUrl: [], apiKey: [], model: [] } + } if (agentType === "command_code") { // Command Code's non-interactive sign-in is `COMMAND_CODE_API_KEY`, which // it reads ahead of the login `command-code login` stores. It talks only @@ -11359,6 +11365,8 @@ base_url = "https://api.openai.com/v1"`} ) } /> + ) : selectedAgent.agent_type === "minimax_code" ? ( + ) : selectedAgent.agent_type === "kimi_code" ? ( { hermes: appSet.has("hermes"), code_buddy: appSet.has("code_buddy"), kimi_code: appSet.has("kimi_code"), + minimax_code: appSet.has("minimax_code"), grok: appSet.has("grok"), cursor: appSet.has("cursor"), deepseek: appSet.has("deepseek"), diff --git a/src/components/settings/minimax-code-config-panel.test.tsx b/src/components/settings/minimax-code-config-panel.test.tsx new file mode 100644 index 00000000000..1d048f0f7c0 --- /dev/null +++ b/src/components/settings/minimax-code-config-panel.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it } from "vitest" + +import messages from "@/i18n/messages/en.json" +import { MiniMaxCodeConfigPanel } from "./minimax-code-config-panel" + +it("explains native sign-in without collecting or saving credentials", () => { + render( + + + + ) + expect(screen.getByText("mcode login")).toBeInTheDocument() + expect(screen.getByText("mcode login --region global")).toBeInTheDocument() + expect(screen.getByText(/existing BYOK configuration/)).toBeInTheDocument() + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + expect(screen.queryByRole("button")).not.toBeInTheDocument() +}) + +describe("MiniMax Code translations", () => { + const locales = import.meta.glob<{ default: typeof messages }>( + "../../i18n/messages/*.json", + { eager: true } + ) + + it("provides setup instructions in every supported locale", () => { + expect(Object.keys(locales)).toHaveLength(10) + for (const locale of Object.values(locales)) { + expect(Object.keys(locale.default.AcpAgentSettings.minimaxCode)).toEqual( + Object.keys(messages.AcpAgentSettings.minimaxCode) + ) + } + }) +}) diff --git a/src/components/settings/minimax-code-config-panel.tsx b/src/components/settings/minimax-code-config-panel.tsx new file mode 100644 index 00000000000..fe5c9cd8e56 --- /dev/null +++ b/src/components/settings/minimax-code-config-panel.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTranslations } from "next-intl" + +/** MiniMax owns authentication and provider configuration. This panel never + * reads, copies or writes credentials into codeg's settings. */ +export function MiniMaxCodeConfigPanel() { + const t = useTranslations("AcpAgentSettings.minimaxCode") + + return ( +
+
+

{t("title")}

+

+ {t("description")} +

+
+
+

{t("loginHint")}

+ + mcode login + +

{t("globalHint")}

+ + mcode login --region global + +
+

{t("modelHint")}

+
+ ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 5afa8cd2753..c3a8ea9bff5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "أدخل عنوان URL صالحًا بصيغة http(s).", "loginHint": "لا تملك مفتاح API؟ شغّل «codebuddy» في الطرفية لتسجيل الدخول بحساب Tencent بدلاً من ذلك." }, + "minimaxCode": { + "title": "إعداد MiniMax Code", + "description": "يعيد codeg استخدام بيانات اعتماد mcode الأصلية والمزوّد المحدد. اضبط مفتاح API في mcode وليس هنا. لا يتطلب إعداد BYOK الحالي تسجيل دخول إضافياً.", + "loginHint": "لاشتراك MiniMax، نفّذ هذا في الطرفية على الجهاز الذي يشغّل codeg (منطقة الصين):", + "globalHint": "للحساب العالمي، استخدم:", + "modelHint": "بعد الإعداد، أعد توصيل الوكيل. يوفّر mcode النماذج وأوضاع الأذونات المتاحة عبر ACP في المحادثة." + }, "kimiCode": { "configManagement": "إعدادات Kimi Code", "configDescription": "لا يقبل `kimi acp` سوى رمز تسجيل دخول مخزَّن، لذا يكتب codeg مزوّدًا مُدارًا في ~/.kimi-code/config.toml ويزرع رمز بوابة محليًا — أما الاستدلال فيظل يعمل بمفتاحك أنت.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4663984282e..bac19dae8b1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "Gib eine gültige http(s)-URL ein.", "loginHint": "Kein API-Schlüssel? Führe „codebuddy“ im Terminal aus, um dich stattdessen mit deinem Tencent-Konto anzumelden." }, + "minimaxCode": { + "title": "MiniMax Code einrichten", + "description": "codeg verwendet die nativen Zugangsdaten und den gewählten Anbieter von mcode. Konfiguriere deinen API-Schlüssel in mcode, nicht hier. Eine bestehende BYOK-Konfiguration benötigt keine zusätzliche Anmeldung.", + "loginHint": "Für ein MiniMax-Abo im Terminal auf dem Rechner mit codeg ausführen (Region China):", + "globalHint": "Für ein globales Konto:", + "modelHint": "Verbinde den Agenten nach der Einrichtung erneut. Verfügbare Modelle und Berechtigungsmodi stellt mcode über ACP im Gespräch bereit." + }, "kimiCode": { "configManagement": "Kimi-Code-Konfiguration", "configDescription": "`kimi acp` akzeptiert nur ein gespeichertes Login-Token, daher schreibt codeg einen verwalteten Provider in ~/.kimi-code/config.toml und legt lokal ein Gate-Token an – die Inferenz läuft weiterhin über deinen eigenen Schlüssel.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1affa7e78ff..99cb953d9f8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "Enter a valid http(s) URL.", "loginHint": "No API key? Run “codebuddy” in a terminal to sign in with your Tencent account instead." }, + "minimaxCode": { + "title": "MiniMax Code setup", + "description": "codeg reuses mcode’s native credentials and selected provider. Configure your own API key in mcode, not here; existing BYOK configuration does not require a separate login.", + "loginHint": "For a MiniMax subscription, run this in a terminal on the machine running codeg (China region):", + "globalHint": "For a global account, use:", + "modelHint": "After setup, reconnect the agent. Available models and permission modes are reported by mcode through ACP in the conversation." + }, "kimiCode": { "configManagement": "Kimi Code Configuration", "configDescription": "`kimi acp` only accepts a stored login token, so codeg writes a managed provider into ~/.kimi-code/config.toml and seeds a local gate token — inference still runs on your own key.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2b7fc0b0130..36eaf6a2a7f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "Introduce una URL http(s) válida.", "loginHint": "¿No tienes clave de API? Ejecuta «codebuddy» en una terminal para iniciar sesión con tu cuenta de Tencent." }, + "minimaxCode": { + "title": "Configuración de MiniMax Code", + "description": "codeg reutiliza las credenciales nativas y el proveedor seleccionado de mcode. Configura tu clave API en mcode, no aquí. Una configuración BYOK existente no requiere otro inicio de sesión.", + "loginHint": "Para una suscripción MiniMax, ejecuta esto en un terminal del equipo donde se ejecuta codeg (región China):", + "globalHint": "Para una cuenta global:", + "modelHint": "Tras configurar, vuelve a conectar el agente. mcode proporciona los modelos y modos de permisos disponibles mediante ACP en la conversación." + }, "kimiCode": { "configManagement": "Configuración de Kimi Code", "configDescription": "`kimi acp` solo acepta un token de sesión almacenado, así que codeg escribe un proveedor gestionado en ~/.kimi-code/config.toml y siembra un token de acceso local: la inferencia sigue usando tu propia clave.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e9564a073e3..2ddada56ae4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "Saisissez une URL http(s) valide.", "loginHint": "Pas de clé API ? Exécutez « codebuddy » dans un terminal pour vous connecter avec votre compte Tencent." }, + "minimaxCode": { + "title": "Configuration de MiniMax Code", + "description": "codeg réutilise les identifiants natifs et le fournisseur sélectionné de mcode. Configurez votre clé API dans mcode, pas ici. Une configuration BYOK existante ne nécessite pas de connexion supplémentaire.", + "loginHint": "Pour un abonnement MiniMax, exécutez ceci dans un terminal sur la machine exécutant codeg (région Chine) :", + "globalHint": "Pour un compte international :", + "modelHint": "Après la configuration, reconnectez l’agent. mcode fournit les modèles et modes d’autorisation disponibles via ACP dans la conversation." + }, "kimiCode": { "configManagement": "Configuration de Kimi Code", "configDescription": "`kimi acp` n'accepte qu'un jeton de connexion enregistré ; codeg écrit donc un fournisseur géré dans ~/.kimi-code/config.toml et dépose un jeton d'accès local — l'inférence utilise toujours votre propre clé.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c7aa5ba2701..72183036276 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "有効な http(s) URL を入力してください。", "loginHint": "API キーがない場合は、ターミナルで「codebuddy」を実行して Tencent アカウントでサインインすることもできます。" }, + "minimaxCode": { + "title": "MiniMax Code の設定", + "description": "codeg は mcode の認証情報と選択済みプロバイダーを再利用します。API キーはここではなく mcode で設定してください。既存の BYOK 設定では追加のログインは不要です。", + "loginHint": "MiniMax サブスクリプションでは、codeg を実行するマシンのターミナルで実行してください(中国リージョン):", + "globalHint": "グローバルアカウントの場合:", + "modelHint": "設定後、エージェントに再接続してください。利用可能なモデルと権限モードは mcode が ACP を通じて会話内に提供します。" + }, "kimiCode": { "configManagement": "Kimi Code の設定", "configDescription": "`kimi acp` は保存済みのログイントークンしか受け付けないため、codeg は ~/.kimi-code/config.toml に管理対象プロバイダーを書き込み、ローカルにゲートトークンを配置します。推論は引き続きご自身のキーで実行されます。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f1b5be5fd71..c1b3043ca0e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "유효한 http(s) URL을 입력하세요.", "loginHint": "API 키가 없나요? 터미널에서 ‘codebuddy’를 실행하여 Tencent 계정으로 로그인할 수도 있습니다." }, + "minimaxCode": { + "title": "MiniMax Code 설정", + "description": "codeg는 mcode의 기본 인증 정보와 선택한 공급자를 재사용합니다. API 키는 여기가 아닌 mcode에서 설정하세요. 기존 BYOK 설정에는 별도 로그인이 필요하지 않습니다.", + "loginHint": "MiniMax 구독은 codeg를 실행하는 컴퓨터의 터미널에서 로그인하세요(중국 리전):", + "globalHint": "글로벌 계정은 다음을 사용하세요:", + "modelHint": "설정 후 에이전트를 다시 연결하세요. 사용 가능한 모델과 권한 모드는 mcode가 ACP를 통해 대화에 제공합니다." + }, "kimiCode": { "configManagement": "Kimi Code 설정", "configDescription": "`kimi acp`는 저장된 로그인 토큰만 인정하므로, codeg가 ~/.kimi-code/config.toml에 관리형 공급자를 기록하고 로컬에 게이트 토큰을 심습니다. 추론은 여전히 사용자의 키로 실행됩니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c065ad015d1..302ca2e755c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "Insira uma URL http(s) válida.", "loginHint": "Sem chave de API? Execute “codebuddy” em um terminal para fazer login com sua conta Tencent." }, + "minimaxCode": { + "title": "Configuração do MiniMax Code", + "description": "O codeg reutiliza as credenciais nativas e o provedor selecionado do mcode. Configure sua chave de API no mcode, não aqui. Uma configuração BYOK existente não exige outro login.", + "loginHint": "Para uma assinatura MiniMax, execute no terminal da máquina que executa o codeg (região China):", + "globalHint": "Para uma conta global:", + "modelHint": "Após configurar, reconecte o agente. O mcode fornece os modelos e modos de permissão disponíveis via ACP na conversa." + }, "kimiCode": { "configManagement": "Configuração do Kimi Code", "configDescription": "O `kimi acp` só aceita um token de login armazenado, por isso o codeg grava um provedor gerenciado em ~/.kimi-code/config.toml e cria um token de acesso local — a inferência continua usando a sua própria chave.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 1599d28db14..f24266e428f 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "请输入合法的 http(s) 地址。", "loginHint": "没有 API Key?也可以在终端运行 “codebuddy” 用腾讯云账号登录。" }, + "minimaxCode": { + "title": "MiniMax Code 设置", + "description": "codeg 复用 mcode 原生凭据与已选服务商。请在 mcode 中配置自己的 API 密钥,无需在此重复保存;已有 BYOK 配置无需额外登录。", + "loginHint": "使用 MiniMax 订阅时,请在运行 codeg 的机器终端中执行(中国区):", + "globalHint": "国际区账号请使用:", + "modelHint": "完成配置后重新连接智能体。可用模型和权限模式由 mcode 通过 ACP 在会话中提供。" + }, "kimiCode": { "configManagement": "Kimi Code 配置", "configDescription": "`kimi acp` 只认已存储的登录令牌,所以 codeg 会在 ~/.kimi-code/config.toml 写入受管供应商,并在本地播种一个门控令牌——推理仍然走你自己的 Key。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 85c97b80e59..db9ceb52f3f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1450,6 +1450,13 @@ "baseUrlInvalid": "請輸入合法的 http(s) 位址。", "loginHint": "沒有 API Key?也可以在終端機執行「codebuddy」用騰訊雲帳號登入。" }, + "minimaxCode": { + "title": "MiniMax Code 設定", + "description": "codeg 沿用 mcode 原生憑證與所選服務商。請在 mcode 中設定自己的 API 金鑰,不必在此重複儲存;已有 BYOK 設定無須額外登入。", + "loginHint": "使用 MiniMax 訂閱時,請在執行 codeg 的機器終端中執行(中國區):", + "globalHint": "國際區帳號請使用:", + "modelHint": "完成設定後重新連線智慧體。可用模型和權限模式由 mcode 透過 ACP 在對話中提供。" + }, "kimiCode": { "configManagement": "Kimi Code 設定", "configDescription": "`kimi acp` 只認已儲存的登入權杖,所以 codeg 會在 ~/.kimi-code/config.toml 寫入受管供應商,並在本機植入一個門控權杖——推論仍然走你自己的 Key。", diff --git a/src/lib/minimax-code.test.ts b/src/lib/minimax-code.test.ts new file mode 100644 index 00000000000..ba5b8cd4897 --- /dev/null +++ b/src/lib/minimax-code.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" + +import { getAgentColor, getAgentLabel } from "@/lib/custom-agents" +import { + AGENT_DISPLAY_ORDER, + ALL_AGENT_TYPES, + compareAgentType, + isCustomAgentType, +} from "@/lib/types" + +describe("MiniMax Code built-in identity", () => { + it("appears exactly once in the built-in selection and display lists", () => { + expect(ALL_AGENT_TYPES.filter((type) => type === "minimax_code")).toEqual([ + "minimax_code", + ]) + expect( + AGENT_DISPLAY_ORDER.filter((type) => type === "minimax_code") + ).toEqual(["minimax_code"]) + expect(isCustomAgentType("minimax_code")).toBe(false) + expect(compareAgentType("minimax_code", "custom:test")).toBeLessThan(0) + }) + + it("has a stable name and color without a custom-agent registration", () => { + expect(getAgentLabel("minimax_code")).toBe("MiniMax Code") + expect(getAgentColor("minimax_code")).toBe("bg-[#F23F5D]") + }) +}) diff --git a/src/lib/session-settings.test.ts b/src/lib/session-settings.test.ts index 66846047afe..086ae347128 100644 --- a/src/lib/session-settings.test.ts +++ b/src/lib/session-settings.test.ts @@ -335,3 +335,22 @@ describe("buildSessionSettings — values", () => { expect(model.target).toEqual({ type: "config", configId: "model" }) }) }) + +describe("MiniMax Code ACP model settings", () => { + it("uses the native model config option rather than a legacy model setter", () => { + const settings = build([ + select("model", "Model", "model", "m:minimax:MiniMax-M2.7:u", [ + "m:minimax:MiniMax-M2.7:u", + "m:custom%3Aprovider:vendor%2Fmodel:v:fast", + ]), + ]) + const model = choice(settings[0]) + expect(model.isModel).toBe(true) + expect(model.currentValue).toBe("m:minimax:MiniMax-M2.7:u") + expect(model.target).toEqual({ type: "config", configId: "model" }) + expect(model.groups[0].options.map((option) => option.value)).toEqual([ + "m:minimax:MiniMax-M2.7:u", + "m:custom%3Aprovider:vendor%2Fmodel:v:fast", + ]) + }) +}) diff --git a/src/lib/types.ts b/src/lib/types.ts index 9ebc07e96c3..1d8cbed9ce9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,4 @@ -/** The nineteen agents codeg ships hand-written support for. */ +/** The built-in agents codeg ships hand-written support for. */ export type BuiltinAgentType = | "claude_code" | "codex" @@ -9,6 +9,7 @@ export type BuiltinAgentType = | "hermes" | "code_buddy" | "kimi_code" + | "minimax_code" | "pi" | "grok" | "cursor" @@ -1113,6 +1114,7 @@ export const AGENT_DISPLAY_ORDER: BuiltinAgentType[] = [ "hermes", "code_buddy", "kimi_code", + "minimax_code", "pi", "grok", "cursor", @@ -1151,6 +1153,7 @@ export const ALL_AGENT_TYPES: BuiltinAgentType[] = [ "hermes", "code_buddy", "kimi_code", + "minimax_code", "pi", "grok", "cursor", @@ -1466,6 +1469,7 @@ export const AGENT_LABELS: Record = { hermes: "Hermes Agent", code_buddy: "CodeBuddy", kimi_code: "Kimi Code", + minimax_code: "MiniMax Code", pi: "Pi", grok: "Grok", cursor: "Cursor", @@ -1488,6 +1492,7 @@ export const AGENT_COLORS: Record = { hermes: "bg-amber-500", code_buddy: "bg-[#0052D9]", kimi_code: "bg-[#1783FF]", + minimax_code: "bg-[#F23F5D]", pi: "bg-[#0D9488]", grok: "bg-neutral-900", cursor: "bg-zinc-800", @@ -4579,6 +4584,7 @@ export type McpAppType = | "hermes" | "code_buddy" | "kimi_code" + | "minimax_code" | "grok" | "cursor" | "deepseek" From 30941cdf1aca75a7564fd688b4f847200a7d92c3 Mon Sep 17 00:00:00 2001 From: xy3 <120182408@qq.com> Date: Sat, 24 Oct 2026 11:57:18 +0800 Subject: [PATCH 02/11] docs: consistently count twenty built-in agents --- README.md | 4 ++-- docs/readme/README.ar.md | 4 ++-- docs/readme/README.de.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.fr.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.pt.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- docs/readme/README.zh-TW.md | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 72a024a2a6e..f73e3848e04 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ ### One Interface for Every Agent -Nineteen agents, plus any ACP agent, in one rich chat with tool cards, live diffs, and interactive replies. +Twenty agents, plus any ACP agent, in one rich chat with tool cards, live diffs, and interactive replies. [Docs →](https://docs.codeg.app/guide/supported-agents) · [Custom agents →](https://docs.codeg.app/guide/custom-agents) · [UI Render →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 Supported Agents -Codeg talks to every agent over the [Agent Client Protocol](https://agentclientprotocol.com), so each one gets the same structured interface. Nineteen come built in, and Codeg installs, pins, and updates most of them for you: +Codeg talks to every agent over the [Agent Client Protocol](https://agentclientprotocol.com), so each one gets the same structured interface. Twenty come built in, and Codeg installs, pins, and updates most of them for you:

Claude Code   diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 0bf1c68a5a7..c239e343a4f 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -31,7 +31,7 @@

🧩 一個介面,容納所有智慧體
內建十九個智慧體,任何相容 ACP 的智慧體也都能加入——全部呈現為同一套結構化對話,而不是一個終端機。
🧩 一個介面,容納所有智慧體
內建二十個智慧體,任何相容 ACP 的智慧體也都能加入——全部呈現為同一套結構化對話,而不是一個終端機。
🔎 工作階段自由流轉
匯入、搜尋、接續每個智慧體留在磁碟上的歷史,還能把它交給另一個智慧體接手。
- + @@ -101,7 +101,7 @@ ### واجهة واحدة لكل الوكلاء -19 وكيلًا مدمجًا وأي وكيل ACP، في محادثة واحدة مع بطاقات أدوات وفروق مباشرة وردود تفاعلية. +20 وكيلًا مدمجًا وأي وكيل ACP، في محادثة واحدة مع بطاقات أدوات وفروق مباشرة وردود تفاعلية. [التوثيق ←](https://docs.codeg.app/guide/supported-agents) · [الوكلاء المخصّصون ←](https://docs.codeg.app/guide/custom-agents) · [عرض الواجهة ←](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 8c713c14a97..0af029364cc 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -101,7 +101,7 @@ ### Eine Oberfläche für alle Agenten -19 integrierte und beliebige ACP-Agenten: ein Chat mit Tool-Karten, Live-Diffs und interaktiven Antworten. +20 integrierte und beliebige ACP-Agenten: ein Chat mit Tool-Karten, Live-Diffs und interaktiven Antworten. [Doku →](https://docs.codeg.app/guide/supported-agents) · [Eigene Agenten →](https://docs.codeg.app/guide/custom-agents) · [UI-Rendering →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 Unterstützte Agenten -Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentclientprotocol.com), sodass jeder dieselbe strukturierte Oberfläche bekommt. Neunzehn sind eingebaut, und die meisten davon installiert, fixiert und aktualisiert Codeg für dich: +Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentclientprotocol.com), sodass jeder dieselbe strukturierte Oberfläche bekommt. Zwanzig sind eingebaut, und die meisten davon installiert, fixiert und aktualisiert Codeg für dich:

Claude Code   diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 1e86badfbdb..af0c3e088e5 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -101,7 +101,7 @@ ### Una interfaz para todos los agentes -19 agentes y cualquier agente ACP, con tarjetas de herramientas, diffs y respuestas interactivas. +20 agentes y cualquier agente ACP, con tarjetas de herramientas, diffs y respuestas interactivas. [Documentación →](https://docs.codeg.app/guide/supported-agents) · [Agentes personalizados →](https://docs.codeg.app/guide/custom-agents) · [Renderizado de UI →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 Agentes compatibles -Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://agentclientprotocol.com), así que todos reciben la misma interfaz estructurada. Diecinueve vienen integrados, y Codeg instala, fija la versión y actualiza la mayoría de ellos por ti: +Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://agentclientprotocol.com), así que todos reciben la misma interfaz estructurada. Veinte vienen integrados, y Codeg instala, fija la versión y actualiza la mayoría de ellos por ti:

Claude Code   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index b8af933574b..b6674d89792 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -101,7 +101,7 @@ ### Une interface pour tous les agents -19 agents intégrés et tout agent ACP, dans un même chat avec cartes d'outils, diffs et réponses interactives. +20 agents intégrés et tout agent ACP, dans un même chat avec cartes d'outils, diffs et réponses interactives. [Documentation →](https://docs.codeg.app/guide/supported-agents) · [Agents personnalisés →](https://docs.codeg.app/guide/custom-agents) · [Rendu d'interface →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 Agents pris en charge -Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentclientprotocol.com) : chacun bénéficie ainsi de la même interface structurée. Dix-neuf sont intégrés, et Codeg installe, épingle et met à jour la plupart d'entre eux pour vous : +Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentclientprotocol.com) : chacun bénéficie ainsi de la même interface structurée. Vingt sont intégrés, et Codeg installe, épingle et met à jour la plupart d'entre eux pour vous :

Claude Code   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 5837b48971b..f6b84d756a6 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -101,7 +101,7 @@ ### すべてのエージェントを、ひとつのインターフェースで -19 の内蔵エージェントと任意の ACP エージェントに対応。ツールカードや差分、対話型の返答を同じ画面で。 +20 の内蔵エージェントと任意の ACP エージェントに対応。ツールカードや差分、対話型の返答を同じ画面で。 [ドキュメント →](https://docs.codeg.app/guide/supported-agents) · [カスタムエージェント →](https://docs.codeg.app/guide/custom-agents) · [UI レンダリング →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 対応エージェント -Codeg はすべてのエージェントと [Agent Client Protocol](https://agentclientprotocol.com) で通信するため、どのエージェントにも同じ構造化されたインターフェースが用意されます。19 種を内蔵し、その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます: +Codeg はすべてのエージェントと [Agent Client Protocol](https://agentclientprotocol.com) で通信するため、どのエージェントにも同じ構造化されたインターフェースが用意されます。20 種を内蔵し、その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます:

Claude Code   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index cee889b467d..b4d38697d1d 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -101,7 +101,7 @@ ### 모든 에이전트를 하나의 인터페이스로 -내장 에이전트 19개와 모든 ACP 에이전트를 한 화면에서. 도구 카드, 실시간 diff, 대화형 응답을 제공합니다. +내장 에이전트 20개와 모든 ACP 에이전트를 한 화면에서. 도구 카드, 실시간 diff, 대화형 응답을 제공합니다. [문서 →](https://docs.codeg.app/guide/supported-agents) · [커스텀 에이전트 →](https://docs.codeg.app/guide/custom-agents) · [UI 렌더링 →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 지원 에이전트 -Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientprotocol.com)로 통신하므로, 에이전트마다 같은 구조화된 인터페이스를 갖습니다. 열아홉 개가 기본 내장되어 있으며, 그중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다: +Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientprotocol.com)로 통신하므로, 에이전트마다 같은 구조화된 인터페이스를 갖습니다. 스무 개가 기본 내장되어 있으며, 그중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다:

Claude Code   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index bc687e9a3e2..ba08373d4b2 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -101,7 +101,7 @@ ### Uma interface para todos os agentes -19 agentes integrados e qualquer agente ACP, num chat com cartões de ferramentas, diffs e respostas interativas. +20 agentes integrados e qualquer agente ACP, num chat com cartões de ferramentas, diffs e respostas interativas. [Documentação →](https://docs.codeg.app/guide/supported-agents) · [Agentes personalizados →](https://docs.codeg.app/guide/custom-agents) · [Renderização de UI →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 Agentes suportados -O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agentclientprotocol.com), então cada um ganha a mesma interface estruturada. Dezenove já vêm integrados, e o Codeg instala, fixa a versão e atualiza a maioria deles por você: +O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agentclientprotocol.com), então cada um ganha a mesma interface estruturada. Vinte já vêm integrados, e o Codeg instala, fixa a versão e atualiza a maioria deles por você:

Claude Code   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c97c4701a30..a3e6e320c3e 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -101,7 +101,7 @@ ### 一个界面,容纳所有智能体 -十九个内置智能体,兼容任意 ACP 智能体。工具卡片、实时 diff 与交互式回复,尽在同一界面。 +二十个内置智能体,兼容任意 ACP 智能体。工具卡片、实时 diff 与交互式回复,尽在同一界面。 [文档 →](https://docs.codeg.app/zh/guide/supported-agents) · [自定义智能体 →](https://docs.codeg.app/zh/guide/custom-agents) · [界面渲染 →](https://docs.codeg.app/zh/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 支持的智能体 -Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个智能体对话,所以它们拿到的都是同一套结构化界面。内置十九个,其中大部分 Codeg 都能替你安装、锁定版本并更新: +Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个智能体对话,所以它们拿到的都是同一套结构化界面。内置二十个,其中大部分 Codeg 都能替你安装、锁定版本并更新:

Claude Code   diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index f866a082cc2..f97dc2a03e7 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -101,7 +101,7 @@ ### 一個介面,容納所有智慧體 -十九個內建智慧體,相容任意 ACP 智慧體。工具卡片、即時 diff 與互動式回覆,盡在同一介面。 +二十個內建智慧體,相容任意 ACP 智慧體。工具卡片、即時 diff 與互動式回覆,盡在同一介面。 [文件 →](https://docs.codeg.app/zh/guide/supported-agents) · [自訂智慧體 →](https://docs.codeg.app/zh/guide/custom-agents) · [介面渲染 →](https://docs.codeg.app/zh/guide/skills#cards-tables-and-charts-in-replies) @@ -273,7 +273,7 @@ git diff | codeg-cli -a codex \ ## 🤖 支援的智慧體 -Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個智慧體溝通,所以每一個都能用上同一套結構化介面。內建十九個,其中大部分 Codeg 都能替你安裝、鎖定版本並更新: +Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個智慧體溝通,所以每一個都能用上同一套結構化介面。內建二十個,其中大部分 Codeg 都能替你安裝、鎖定版本並更新:

Claude Code   From c81797fb2ccb456c8b43e501f94f19480f91c17e Mon Sep 17 00:00:00 2001 From: xy3 <120182408@qq.com> Date: Sat, 24 Oct 2026 22:13:00 +0800 Subject: [PATCH 03/11] fix: register MiniMax Code in delegation tool schema --- src-tauri/src/acp/delegation/companion.rs | 18 ++++++++++++++++-- src-tauri/src/acp/delegation/tool_schema.json | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index a0160b448dd..a07065072b9 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -4546,6 +4546,12 @@ mod tests { assert!(agents.iter().any(|a| a == "zcode")); assert!(agents.iter().any(|a| a == "trae")); assert!(agents.iter().any(|a| a == "command_code")); + assert!(agents.iter().any(|a| a == "minimax_code")); + for agent in crate::models::agent::BUILTIN_AGENT_TYPES { + assert!(agents + .iter() + .any(|a| a.as_str() == Some(agent.as_wire().as_ref()))); + } // get_delegation_status takes a single id param — task_ids (required) — // plus wait_ms. The legacy single `task_id` param is gone. let status = tools @@ -4570,6 +4576,7 @@ mod tests { "custom:amp".into(), "custom:goose".into(), "codex".into(), + "minimax_code".into(), ]; let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; let resp = unwrap_respond(dispatch_line(&ctx, Arc::new(InflightCalls::new()), line).await); @@ -4609,7 +4616,12 @@ mod tests { #[tokio::test] async fn disabled_agents_are_subtracted_from_the_delegate_enum() { let mut ctx = ctx(); - ctx.disabled_agents = vec!["codex".into(), "grok".into(), "not-an-agent".into()]; + ctx.disabled_agents = vec![ + "codex".into(), + "grok".into(), + "minimax_code".into(), + "not-an-agent".into(), + ]; ctx.custom_agents = vec!["custom:goose".into()]; let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; let resp = unwrap_respond(dispatch_line(&ctx, Arc::new(InflightCalls::new()), line).await); @@ -4627,10 +4639,11 @@ mod tests { .clone(); assert_eq!( agents.len(), - crate::models::agent::BUILTIN_AGENT_TYPES.len() - 2 + 1 + crate::models::agent::BUILTIN_AGENT_TYPES.len() - 3 + 1 ); assert!(!agents.contains(&serde_json::json!("codex"))); assert!(!agents.contains(&serde_json::json!("grok"))); + assert!(!agents.contains(&serde_json::json!("minimax_code"))); // Survivors keep the embedded order, customs still come last. assert_eq!(agents[0], "claude_code"); assert_eq!(agents[1], "open_code"); @@ -4666,6 +4679,7 @@ mod tests { assert_eq!(agents[16], "zcode"); assert_eq!(agents[17], "trae"); assert_eq!(agents[18], "command_code"); + assert_eq!(agents[19], "minimax_code"); } #[tokio::test] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index e0271ee89a0..13eb0c243bd 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -30,7 +30,8 @@ "kiro", "zcode", "trae", - "command_code" + "command_code", + "minimax_code" ], "description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, From f946543892ab21aaccccf4a82994049c2fca00da Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:18:18 +0800 Subject: [PATCH 04/11] fix(acp): point MiniMax Code permission cards at the call they gate MiniMax Code asks for permission under its own perm_ id, never the call_ id of the tool call it gates. Codeg then filed the approval under an id no transcript row carries, so the approval chip never showed in history, and the live view grew a second, never-settling row for the call. Rewrite the card's toolCallId to the single unsettled call whose input matches the request's rawInput. The request can overtake the call's in_progress update, which the turn loop applies on its own task, so wait up to 250 ms for that call before keeping the agent's id. --- src-tauri/src/acp/connection.rs | 304 ++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7afde67db7e..51de144faac 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -10605,6 +10605,74 @@ async fn handle_elicitation_request( } } +/// The in-flight tool call a MiniMax Code permission request gates. +/// +/// MiniMax Code names the request after its own permission record +/// (`perm_`), never after the call it gates. That call was already +/// announced under its own id (`call_`) and is in progress with the same +/// `rawInput` (verified against 0.7.0). Left alone, the card names an id no +/// row carries: the live view grows a second, never-settling row for the call, +/// and the approval chip is stored under an id the transcript never shows. +/// +/// Only a single unsettled call whose input equals the request's counts. The +/// `_start_line` codeg adds to a live edit's input is ignored. `None` (keep the +/// agent's id) when nothing matches, or when two identical calls are in flight +/// and there is no telling which one is asking. +fn minimax_gated_tool_call_id( + active: &BTreeMap, + raw_input: Option<&serde_json::Value>, +) -> Option { + use crate::acp::session_state::ToolCallStatus; + + fn without_start_line(value: &serde_json::Value) -> serde_json::Value { + let mut value = value.clone(); + if let Some(obj) = value.as_object_mut() { + obj.remove("_start_line"); + } + value + } + + let requested = without_start_line(raw_input?); + let mut gated = active.values().filter(|call| { + matches!( + call.status, + ToolCallStatus::Pending | ToolCallStatus::InProgress + ) && call + .input + .as_ref() + .is_some_and(|input| without_start_line(input) == requested) + }); + let only = gated.next()?; + gated.next().is_none().then(|| only.id.clone()) +} + +/// How long a MiniMax Code permission request waits for the call it gates to +/// show up in the session state. The agent sends the call's `in_progress` +/// update before the request, but the turn loop applies updates on its own +/// task while the request is handled inline by the dispatch loop, so the +/// request can get here first. The update is already queued by then, so the +/// wait is normally a scheduling slice; past it the card keeps the agent's id. +const MINIMAX_GATED_CALL_WAIT: std::time::Duration = std::time::Duration::from_millis(250); + +/// [`minimax_gated_tool_call_id`] against the live session state, waiting up +/// to [`MINIMAX_GATED_CALL_WAIT`] for the call to be applied. A request with no +/// `rawInput` (a question MiniMax Code asks through a permission card) gates +/// no call and does not wait. +async fn await_minimax_gated_tool_call_id( + state: &Arc>, + raw_input: Option<&serde_json::Value>, +) -> Option { + raw_input?; + let deadline = tokio::time::Instant::now() + MINIMAX_GATED_CALL_WAIT; + loop { + let gated = minimax_gated_tool_call_id(&state.read().await.active_tool_calls, raw_input); + if gated.is_some() || tokio::time::Instant::now() >= deadline { + return gated; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} + #[allow(clippy::too_many_arguments)] async fn handle_permission_request( cx: &ConnectionTo, @@ -10673,6 +10741,16 @@ async fn handle_permission_request( let mut tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default(); + // Point a MiniMax Code card at the call it gates, not at the agent's own + // permission id (see `minimax_gated_tool_call_id`). + if agent_type == AgentType::MiniMaxCode { + let gated = + await_minimax_gated_tool_call_id(state, req.tool_call.fields.raw_input.as_ref()).await; + if let (Some(id), Some(obj)) = (gated, tool_call_value.as_object_mut()) { + obj.insert("toolCallId".to_string(), serde_json::Value::String(id)); + } + } + // Kiro's permission `toolCall` names no tool (no `_meta`), and in its raw // shape a `write` reads as the shell command `create` / `strReplace` and // shows no diff. Rewrite it like the live frames (see `kiro_live_tool`), @@ -23925,6 +24003,232 @@ mod tests { assert_eq!(guard.tool_approvals, vec![approval.clone()]); } + fn minimax_tool_call(id: &str, status: &str, raw_input: &str) -> AcpEvent { + AcpEvent::ToolCall { + tool_call_id: id.to_string(), + title: "write".to_string(), + kind: "edit".to_string(), + status: status.to_string(), + content: None, + raw_input: Some(raw_input.to_string()), + raw_output: None, + locations: None, + meta: None, + images: None, + } + } + + /// Which call a MiniMax Code permission request gates: the one unsettled + /// call whose input is the request's. + #[test] + fn minimax_code_permission_resolves_to_the_unsettled_call_with_its_input() { + let mut st = SessionState::new( + "conn-test".to_string(), + AgentType::MiniMaxCode, + None, + "win".to_string(), + None, + ); + let write = r#"{"path":"/w/a.txt","content":"x\n"}"#; + st.apply_event(&minimax_tool_call("call_1", "completed", write)); + st.apply_event(&minimax_tool_call("call_2", "in_progress", write)); + st.apply_event(&minimax_tool_call( + "call_3", + "in_progress", + r#"{"file_path":"/w/b.txt","old_string":"o","new_string":"n","_start_line":4}"#, + )); + let gated = |st: &SessionState, input: Option<&serde_json::Value>| { + minimax_gated_tool_call_id(&st.active_tool_calls, input) + }; + let write_input = serde_json::json!({"path": "/w/a.txt", "content": "x\n"}); + let edit_input = + serde_json::json!({"file_path": "/w/b.txt", "old_string": "o", "new_string": "n"}); + let other_input = serde_json::json!({"path": "/w/c.txt", "content": "x\n"}); + + // The settled call with the same input is not the one asking. + assert_eq!(gated(&st, Some(&write_input)).as_deref(), Some("call_2")); + // The `_start_line` codeg adds to a live edit does not hide it. + assert_eq!(gated(&st, Some(&edit_input)).as_deref(), Some("call_3")); + // Nothing in flight with that input, or no input at all: keep the + // agent's own id. + assert_eq!(gated(&st, Some(&other_input)), None); + assert_eq!(gated(&st, None), None); + // Two identical calls in flight: no telling which one is asking. + st.apply_event(&minimax_tool_call("call_4", "in_progress", write)); + assert_eq!(gated(&st, Some(&write_input)), None); + } + + /// The request can get here before the turn loop has applied the update + /// that announces the call it gates; the lookup waits for that call, and + /// gives up (keeping the agent's id) when none ever shows up. + #[tokio::test(start_paused = true)] + async fn a_minimax_code_permission_waits_briefly_for_the_call_it_gates() { + let state = Arc::new(RwLock::new(SessionState::new( + "conn-test".to_string(), + AgentType::MiniMaxCode, + None, + "win".to_string(), + None, + ))); + let input = serde_json::json!({"path": "/outside/w.txt", "content": "x\n"}); + let late = tokio::spawn({ + let state = Arc::clone(&state); + async move { + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + state.write().await.apply_event(&minimax_tool_call( + "call_9", + "in_progress", + r#"{"path":"/outside/w.txt","content":"x\n"}"#, + )); + } + }); + assert_eq!( + await_minimax_gated_tool_call_id(&state, Some(&input)) + .await + .as_deref(), + Some("call_9") + ); + late.await.unwrap(); + + let started = tokio::time::Instant::now(); + let other = serde_json::json!({"path": "/elsewhere.txt", "content": "y\n"}); + let gated = await_minimax_gated_tool_call_id(&state, Some(&other)).await; + assert_eq!(gated, None); + assert!(started.elapsed() >= MINIMAX_GATED_CALL_WAIT); + // A question asked through a permission card has no input to match. + let started = tokio::time::Instant::now(); + assert_eq!(await_minimax_gated_tool_call_id(&state, None).await, None); + assert_eq!(started.elapsed(), std::time::Duration::ZERO); + } + + /// The card a MiniMax Code permission request raises, and the approval + /// recorded from its answer, name the call it gates rather than the + /// agent's own `perm_` id. + #[tokio::test] + async fn a_minimax_code_permission_card_names_the_call_it_gates() { + use agent_client_protocol::schema::v1::{ + PermissionOption, ToolCallUpdate, ToolCallUpdateFields, + }; + use agent_client_protocol::Channel; + + let (client_end, agent_end) = Channel::duplex(); + let state = Arc::new(RwLock::new(SessionState::new( + "conn-test".to_string(), + AgentType::MiniMaxCode, + None, + "win".to_string(), + None, + ))); + state.write().await.apply_event(&minimax_tool_call( + "call_7", + "in_progress", + r#"{"path":"/outside/w.txt","content":"x\n"}"#, + )); + let perms = PendingPermissions::default(); + + let agent = tokio::spawn(Agent.builder().connect_with( + agent_end, + async |cx: ConnectionTo| { + cx.send_request_to( + Client, + RequestPermissionRequest::new( + SessionId::new("s1"), + ToolCallUpdate::new( + "perm_4a0b3f1b", + ToolCallUpdateFields::new().title("write").raw_input( + serde_json::json!({"path": "/outside/w.txt", "content": "x\n"}), + ), + ), + vec![ + PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + ), + PermissionOption::new("deny", "Deny", PermissionOptionKind::RejectOnce), + ], + ), + ) + .block_task() + .await + }, + )); + + let client = tokio::spawn({ + let state = Arc::clone(&state); + let perms = perms.clone(); + Client + .builder() + .on_receive_request( + async move |req: RequestPermissionRequest, + responder: Responder, + cx: ConnectionTo| { + handle_permission_request( + &cx, + &state, + &EventEmitter::Noop, + &perms, + "/tmp", + AgentType::MiniMaxCode, + req, + responder, + ) + .await; + Ok(()) + }, + on_receive_request!(), + ) + .connect_with(client_end, async |_cx: ConnectionTo| { + std::future::pending::>().await + }) + }); + + let shown = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Some(id) = perms.lock().await.showing.clone() { + break id; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("the card is shown"); + assert_eq!( + state + .read() + .await + .pending_permission + .as_ref() + .map(|p| p.tool_call_id.as_str()), + Some("call_7") + ); + + resolve_permission( + &perms, + &state, + &EventEmitter::Noop, + shown, + "allow-once".to_string(), + PermissionAnswerOrigin::User, + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), agent) + .await + .expect("the agent gets its answer") + .expect("agent task") + .expect("a response, not an error"); + client.abort(); + + let approvals: Vec = state + .read() + .await + .tool_approvals + .iter() + .map(|a| a.tool_call_id.clone()) + .collect(); + assert_eq!(approvals, vec!["call_7".to_string()]); + } + /// An agent request codeg has no handler for still gets an answer. One /// that names the session is claimed by the session router — ahead of the /// runtime's own `method_not_found` fallback — so without an explicit reply From 6a2a3f3ad7e6d2e4736154ee17e840bc5a7df448 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:18:18 +0800 Subject: [PATCH 05/11] fix(acp): treat the registry's minimax-code entry as the built-in The ACP registry publishes MiniMax Code as minimax-code, while the built-in uses codeg-minimax-code. Without an alias the add-agent picker offered the registry entry as a second, redundant MiniMax Code next to the built-in. --- src-tauri/src/acp/remote_registry.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/acp/remote_registry.rs b/src-tauri/src/acp/remote_registry.rs index 1fd62db5436..0e189d8011f 100644 --- a/src-tauri/src/acp/remote_registry.rs +++ b/src-tauri/src/acp/remote_registry.rs @@ -266,11 +266,14 @@ pub async fn fetch_catalog( /// id is `kimi-code`, and Qoder as `qoder` against codeg's `qoder-cli`. Kiro is /// not in the registry yet, but its vendor's pending entry /// (agentclientprotocol/registry#561) is `kiro`, against codeg's `kiro-cli`. +/// MiniMax Code is published as `minimax-code`, against codeg's +/// `codeg-minimax-code` (prefixed so a `minimax-code` custom agent added before +/// the built-in keeps resolving to its own definition). /// /// Missing an alias is not merely redundant, it is a DEAD entry: the picker /// lists the agent as addable, and the add then fails validation because the /// id collides with a built-in wire name (`is_valid_custom_agent_id`). -const BUILTIN_REGISTRY_ALIASES: &[&str] = &["kimi", "qoder", "kiro"]; +const BUILTIN_REGISTRY_ALIASES: &[&str] = &["kimi", "qoder", "kiro", "minimax-code"]; /// Whether an id is one of codeg's hand-written built-in agents. Deliberately /// does NOT consult the custom registry (unlike `registry::from_registry_id`, @@ -489,6 +492,10 @@ mod tests { // `kiro-cli` here). assert!(is_builtin_registry_id("kiro")); assert!(is_builtin_registry_id("kiro-cli")); + // And for MiniMax Code (`minimax-code` upstream, `codeg-minimax-code` + // here). + assert!(is_builtin_registry_id("minimax-code")); + assert!(is_builtin_registry_id("codeg-minimax-code")); // Antigravity needs NO alias: codeg's registry id is byte-identical // to the one the ACP registry publishes, so the picker resolves it // through the built-in table and never offers a duplicate entry. @@ -500,9 +507,10 @@ mod tests { // Why a missing alias is a BUG and not just noise. The entries in the // table fail differently, and Qoder and Kiro are the worse kind: // - // * `kimi` IS a legal custom-agent id (codeg's wire name is `kimi_code`), - // so without the alias the picker would let the user add a SECOND, - // redundant Kimi alongside the built-in. + // * `kimi` and `minimax-code` ARE legal custom-agent ids (codeg's wire + // names are `kimi_code` and `minimax_code`), so without the alias the + // picker would let the user add a SECOND, redundant copy alongside the + // built-in. // * `qoder` and `kiro` are NOT (they are codeg's wire names, blocked // outright), so without the alias the picker lists an entry whose "Add" // button can only ever error — a dead end with no way for the user to @@ -510,6 +518,9 @@ mod tests { #[test] fn aliases_that_are_addable_would_duplicate_and_the_rest_would_dead_end() { assert!(crate::models::agent::is_valid_custom_agent_id("kimi")); + assert!(crate::models::agent::is_valid_custom_agent_id( + "minimax-code" + )); assert!(!crate::models::agent::is_valid_custom_agent_id("qoder")); assert!(!crate::models::agent::is_valid_custom_agent_id("kiro")); for alias in BUILTIN_REGISTRY_ALIASES { From 10841f7c07a6d1e5f6a5c4012cb0050837865409 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:18:18 +0800 Subject: [PATCH 06/11] test(acp): pin CODEG_HOME in the MiniMax MCP overlay path test The test read mcp_json_path() outside temp_env's lock, so a parallel test changing CODEG_HOME or CODEG_DATA_DIR could make the two reads disagree. --- src-tauri/src/acp/minimax_code.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/acp/minimax_code.rs b/src-tauri/src/acp/minimax_code.rs index 02fa19eb0a5..cc4c9ace0cd 100644 --- a/src-tauri/src/acp/minimax_code.rs +++ b/src-tauri/src/acp/minimax_code.rs @@ -16,16 +16,27 @@ pub(crate) fn mcp_json_path() -> PathBuf { mod tests { use super::*; + // Every read happens under temp_env's lock, CODEG_HOME pinned: the path + // follows CODEG_HOME / CODEG_DATA_DIR, which other tests set in parallel. #[test] fn minimax_mcp_overlay_is_independent_of_native_profile_overrides() { - let expected = mcp_json_path(); - assert!(expected.ends_with("mcp/minimax_code.json")); + let home = tempfile::tempdir().unwrap(); temp_env::with_vars( [ - ("MINIMAX_DATA_DIR", Some("/native/primary")), - ("MAVIS_DATA_DIR", Some("/native/legacy")), + ("CODEG_HOME", Some(home.path().as_os_str())), + ("CODEG_DATA_DIR", None), ], - || assert_eq!(mcp_json_path(), expected), + || { + let expected = home.path().join("mcp").join("minimax_code.json"); + assert_eq!(mcp_json_path(), expected); + temp_env::with_vars( + [ + ("MINIMAX_DATA_DIR", Some("/native/primary")), + ("MAVIS_DATA_DIR", Some("/native/legacy")), + ], + || assert_eq!(mcp_json_path(), expected), + ); + }, ); } } From a7f1fd7873c5092a1d26dc647316c886bfbf9e2c Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:18:18 +0800 Subject: [PATCH 07/11] docs: record what was verified against MiniMax Code 0.7.0 Note the installer-directory fallback, the 0.7.0 prompt capabilities, the offline end-to-end run through codeg-server, and that mcode sends codeg no fs/* or terminal/* requests. --- docs/agents/minimax-code.md | 6 ++++-- src-tauri/src/acp/file_system_runtime.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/agents/minimax-code.md b/docs/agents/minimax-code.md index 617df4d2782..de9d8e31491 100644 --- a/docs/agents/minimax-code.md +++ b/docs/agents/minimax-code.md @@ -20,7 +20,7 @@ irm https://filecdn.minimax.chat/public/install.ps1 | iex These are MiniMax's official installation commands. Review their scripts before running them. The installer prepares a compatible Node.js runtime when needed; its one-command Linux installer does not support Alpine/musl. See the [official installation guide](https://github.com/MiniMax-AI/minimax-code/blob/da22f11c55d4748155d7162c1030414228a00565/docs/installation.md) for alternatives and requirements. -Reopen the terminal and restart Codeg if necessary so its backend inherits the updated PATH. Check `mcode --version` and `mcode --help`. Then sign in using the region of your account: +Reopen the terminal and restart Codeg if necessary so its backend inherits the updated PATH. When `mcode` is not on that PATH, Codeg also looks for the installer's launcher in `~/.minimax-code/bin`, or `%USERPROFILE%\.minimax-code` on Windows. Check `mcode --version` and `mcode --help`. Then sign in using the region of your account: ```sh # Mainland China account @@ -52,7 +52,7 @@ Manage skills through MiniMax Code itself. Codeg-managed skill installation is n ## Attachments and current scope -Attachment support follows the installed agent's negotiated ACP capabilities. Published MiniMax Code 0.6.13 advertises image support, but not audio or embedded context. File links remain references for native tools. An advertised capability does not guarantee that the selected provider/model supports that input. +Attachment support follows the installed agent's negotiated ACP capabilities. Published MiniMax Code 0.6.13 and 0.7.0 advertise image support, but not audio or embedded context. File links remain references for native tools. An advertised capability does not guarantee that the selected provider/model supports that input. Upstream supports stdio, HTTP and SSE MCP transports; Codeg's supported configuration paths and transports are a separate integration concern. Native extensions, native history import, provider/credential editing, and desktop-app-only features are outside this integration. @@ -60,6 +60,8 @@ Upstream supports stdio, HTTP and SSE MCP transports; Codeg's supported configur An isolated, unauthenticated `initialize` smoke test passed with `@minimax-ai/code@0.6.13`. It confirmed ACP version 1, session loading, HTTP/SSE MCP, image capability, and session list/fork/resume/close declarations. No credentials or model requests were used. This verifies startup and capability negotiation only, not authenticated conversations, session restoration, tool execution, or end-to-end image processing. +An offline end-to-end run with `@minimax-ai/code@0.7.0` on macOS then drove the agent through Codeg's server. A bring-your-own-key provider pointed at a local OpenAI-compatible test endpoint, so no MiniMax account or real model was involved. It covered the sign-in-required error, shell, read, edit and write tool calls, a permission prompt, reopening a session without duplicated history, Codeg-assigned stdio MCP servers and the collaboration companion's questions, Plan mode, and switching models. Real MiniMax accounts, Windows and Linux were not tested. + ## Upstream references The protocol details above were inspected at upstream commit [`da22f11c55d4748155d7162c1030414228a00565`](https://github.com/MiniMax-AI/minimax-code/commit/da22f11c55d4748155d7162c1030414228a00565). Published CLI releases can differ from this source snapshot. diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index d9092cb658d..4a5667eed95 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -790,7 +790,8 @@ fn agent_root_slots(agent_type: AgentType) -> &'static [RootSlot] { // 0.209.1), so its `~/.trae` never needs to be a write root either. // Command Code is the same case: codeg's transcript is its history, // and its tools never go through `fs/*` (verified against 1.84.0 and - // 1.86.3). + // 1.86.3). So is MiniMax Code: its tools run in-process and it sends + // codeg no `fs/*` or `terminal/*` request (verified against 0.7.0). AgentType::Custom(_) | AgentType::ZCode | AgentType::Trae From 4fa562dba4ed49053cdad84aa493345f39a664ee Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:18:18 +0800 Subject: [PATCH 08/11] docs: give MiniMax Code its icon in the README agent list Every other built-in agent in the ten READMEs shows its icon; the MiniMax Code entry was text only. The SVG mirrors the in-app MiniMaxCodeColorIcon. --- README.md | 2 +- docs/images/agents/minimax-code.svg | 1 + docs/readme/README.ar.md | 2 +- docs/readme/README.de.md | 2 +- docs/readme/README.es.md | 2 +- docs/readme/README.fr.md | 2 +- docs/readme/README.ja.md | 2 +- docs/readme/README.ko.md | 2 +- docs/readme/README.pt.md | 2 +- docs/readme/README.zh-CN.md | 2 +- docs/readme/README.zh-TW.md | 2 +- 11 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 docs/images/agents/minimax-code.svg diff --git a/README.md b/README.md index f73e3848e04..c7794c4d107 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ Codeg talks to every agent over the [Agent Client Protocol](https://agentclientp Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/images/agents/minimax-code.svg b/docs/images/agents/minimax-code.svg new file mode 100644 index 00000000000..344ac523d01 --- /dev/null +++ b/docs/images/agents/minimax-code.svg @@ -0,0 +1 @@ +MiniMax Code diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index c239e343a4f..4ddce0a65c2 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -285,7 +285,7 @@ git diff | codeg-cli -a codex \ Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 0af029364cc..0367c1261b8 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -285,7 +285,7 @@ Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentc Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index af0c3e088e5..a7341dcf83a 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -285,7 +285,7 @@ Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://ag Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index b6674d89792..0bd0c9d143f 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -285,7 +285,7 @@ Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentcl Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index f6b84d756a6..168eb87fc6b 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -285,7 +285,7 @@ Codeg はすべてのエージェントと [Agent Client Protocol](https://agent Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index b4d38697d1d..255ed0e120e 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -285,7 +285,7 @@ Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientproto Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index ba08373d4b2..f6b7105a157 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -285,7 +285,7 @@ O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agent Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index a3e6e320c3e..f1ec86f7294 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -285,7 +285,7 @@ Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个 Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index f97dc2a03e7..a6f5134df59 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -285,7 +285,7 @@ Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個 Hermes Agent   CodeBuddy   Kimi Code   - MiniMax Code   + MiniMax Code   Pi   Grok   Cursor   From 6e86c1e76646e2539428cdcd5a24dbca3d4ffd76 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:35:36 +0800 Subject: [PATCH 09/11] fix(acp): refuse binding a model provider to MiniMax Code The PR keeps generic provider fields off MiniMax Code's launch env, but the bind path still ran parse_provider_model, whose fallback maps the provider's model to OPENAI_MODEL and saves it in the agent's explicit env, which the launch passes through. The UI offers no provider for MiniMax Code, yet the API accepts one for any agent type. Refuse the bind like a cross-type one. --- src-tauri/src/commands/acp.rs | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index ccbccef5dd3..abf3f04a338 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -12723,6 +12723,15 @@ pub(crate) async fn acp_update_agent_env_core( // provider-edit cascade. let mut claude_local_cascade: Option<(String, String, BTreeMap>)> = None; if let Some(pid) = model_provider_id { + // MiniMax Code keeps its own login and providers, and launches with + // nothing but its explicit env (see `build_runtime_env_from_setting`). + // A bound provider's model would reach it anyway, as the OPENAI_MODEL + // that `parse_provider_model` falls back to. + if agent_type == AgentType::MiniMaxCode { + return Err(AcpError::protocol(format!( + "{agent_type} uses its own provider configuration; model provider {pid} cannot be bound to it" + ))); + } let provider = crate::db::service::model_provider_service::get_by_id(&db.conn, pid) .await .map_err(|e| AcpError::protocol(e.to_string()))? @@ -22355,6 +22364,48 @@ model = "gpt" assert!(skill_storage_spec(AgentType::MiniMaxCode).is_none()); } + // Model providers are not offered for MiniMax Code, but the API accepts one + // for any agent type; binding it would persist OPENAI_MODEL. + #[tokio::test] + async fn minimax_code_refuses_a_model_provider_binding() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let provider = crate::db::service::model_provider_service::create( + &db.conn, + "p".to_string(), + "https://example.test/v1".to_string(), + "k".to_string(), + "minimax_code".to_string(), + Some("unexpected-model".to_string()), + ) + .await + .unwrap(); + let err = acp_update_agent_env_core( + AgentType::MiniMaxCode, + true, + BTreeMap::new(), + Some(provider.id), + &db, + &EventEmitter::Noop, + ) + .await + .expect_err("the bind is refused"); + assert!(err.to_string().contains("cannot be bound"), "{err}"); + let setting = agent_setting_service::get_by_agent_type(&db.conn, AgentType::MiniMaxCode) + .await + .unwrap() + .expect("the defaults row exists"); + assert_eq!(setting.model_provider_id, None); + assert!( + !setting + .env_json + .as_deref() + .unwrap_or_default() + .contains("OPENAI_MODEL"), + "{:?}", + setting.env_json + ); + } + #[test] fn an_explicit_cline_provider_env_row_still_wins() { let now = chrono::Utc::now(); From 7e45109c22d2098e16cae2ebdf89c9fa66e6b9da Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 10:55:16 +0800 Subject: [PATCH 10/11] fix(acp): skip calls already asked about when matching a MiniMax card A unique in-progress call with the same input could be stale: an earlier call that was already asked about and answered, whose completion is still queued behind the newer call's announcement. The card and its approval then landed on the older call. An agent asks about a call once, so exclude calls with an unanswered card or a recorded approval. Both are read under the permission queue lock, which resolve_permission also holds while it retires a card and records the approval, so a call is never in neither. --- src-tauri/src/acp/connection.rs | 147 +++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 51de144faac..de3aa159b12 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -3290,6 +3290,14 @@ impl PermissionQueue { fn waiting_len(&self) -> usize { self.waiting.len() } + + /// The tool calls the unanswered cards (on screen or waiting) are about. + fn tool_call_ids(&self) -> impl Iterator { + self.responders + .values() + .map(|parked| parked.tool_call_id.as_str()) + .filter(|id| !id.is_empty()) + } } /// Shared per-connection permission state (responders + display queue). @@ -10615,11 +10623,19 @@ async fn handle_elicitation_request( /// and the approval chip is stored under an id the transcript never shows. /// /// Only a single unsettled call whose input equals the request's counts. The -/// `_start_line` codeg adds to a live edit's input is ignored. `None` (keep the -/// agent's id) when nothing matches, or when two identical calls are in flight -/// and there is no telling which one is asking. +/// `_start_line` codeg adds to a live edit's input is ignored. A call in +/// `claimed` (one a card was already raised for) is never a candidate: an agent +/// asks about a call once, so a second request with the same input is about a +/// newer call, whose announcement may still be queued behind the earlier one's +/// completion. `None` (keep the agent's id) when nothing matches, or when two +/// identical calls are in flight and there is no telling which one is asking. +/// +/// Still a heuristic: two identical calls run in parallel, asked about in the +/// reverse of their announcement order while only the first is applied, swap +/// cards. Their rows show the same input, so only the approval chips swap. fn minimax_gated_tool_call_id( active: &BTreeMap, + claimed: &HashSet<&str>, raw_input: Option<&serde_json::Value>, ) -> Option { use crate::acp::session_state::ToolCallStatus; @@ -10637,10 +10653,11 @@ fn minimax_gated_tool_call_id( matches!( call.status, ToolCallStatus::Pending | ToolCallStatus::InProgress - ) && call - .input - .as_ref() - .is_some_and(|input| without_start_line(input) == requested) + ) && !claimed.contains(call.id.as_str()) + && call + .input + .as_ref() + .is_some_and(|input| without_start_line(input) == requested) }); let only = gated.next()?; gated.next().is_none().then(|| only.id.clone()) @@ -10658,14 +10675,28 @@ const MINIMAX_GATED_CALL_WAIT: std::time::Duration = std::time::Duration::from_m /// to [`MINIMAX_GATED_CALL_WAIT`] for the call to be applied. A request with no /// `rawInput` (a question MiniMax Code asks through a permission card) gates /// no call and does not wait. +/// +/// The claimed calls are the unanswered cards' plus the recorded approvals', +/// read under the queue lock: `resolve_permission` retires a card and records +/// its approval under that same lock, so a call is never in neither. async fn await_minimax_gated_tool_call_id( + perms: &PendingPermissions, state: &Arc>, raw_input: Option<&serde_json::Value>, ) -> Option { raw_input?; let deadline = tokio::time::Instant::now() + MINIMAX_GATED_CALL_WAIT; loop { - let gated = minimax_gated_tool_call_id(&state.read().await.active_tool_calls, raw_input); + let gated = { + // Queue before state: the lock order `PermissionQueue` documents. + let queue = perms.lock().await; + let state = state.read().await; + let claimed: HashSet<&str> = queue + .tool_call_ids() + .chain(state.tool_approvals.iter().map(|a| a.tool_call_id.as_str())) + .collect(); + minimax_gated_tool_call_id(&state.active_tool_calls, &claimed, raw_input) + }; if gated.is_some() || tokio::time::Instant::now() >= deadline { return gated; } @@ -10745,7 +10776,8 @@ async fn handle_permission_request( // permission id (see `minimax_gated_tool_call_id`). if agent_type == AgentType::MiniMaxCode { let gated = - await_minimax_gated_tool_call_id(state, req.tool_call.fields.raw_input.as_ref()).await; + await_minimax_gated_tool_call_id(perms, state, req.tool_call.fields.raw_input.as_ref()) + .await; if let (Some(id), Some(obj)) = (gated, tool_call_value.as_object_mut()) { obj.insert("toolCallId".to_string(), serde_json::Value::String(id)); } @@ -24037,30 +24069,50 @@ mod tests { "in_progress", r#"{"file_path":"/w/b.txt","old_string":"o","new_string":"n","_start_line":4}"#, )); - let gated = |st: &SessionState, input: Option<&serde_json::Value>| { - minimax_gated_tool_call_id(&st.active_tool_calls, input) - }; + let unclaimed = HashSet::new(); + let gated = + |st: &SessionState, claimed: &HashSet<&str>, input: Option<&serde_json::Value>| { + minimax_gated_tool_call_id(&st.active_tool_calls, claimed, input) + }; let write_input = serde_json::json!({"path": "/w/a.txt", "content": "x\n"}); let edit_input = serde_json::json!({"file_path": "/w/b.txt", "old_string": "o", "new_string": "n"}); let other_input = serde_json::json!({"path": "/w/c.txt", "content": "x\n"}); // The settled call with the same input is not the one asking. - assert_eq!(gated(&st, Some(&write_input)).as_deref(), Some("call_2")); + assert_eq!( + gated(&st, &unclaimed, Some(&write_input)).as_deref(), + Some("call_2") + ); // The `_start_line` codeg adds to a live edit does not hide it. - assert_eq!(gated(&st, Some(&edit_input)).as_deref(), Some("call_3")); + assert_eq!( + gated(&st, &unclaimed, Some(&edit_input)).as_deref(), + Some("call_3") + ); // Nothing in flight with that input, or no input at all: keep the // agent's own id. - assert_eq!(gated(&st, Some(&other_input)), None); - assert_eq!(gated(&st, None), None); - // Two identical calls in flight: no telling which one is asking. + assert_eq!(gated(&st, &unclaimed, Some(&other_input)), None); + assert_eq!(gated(&st, &unclaimed, None), None); + // A call a card was already raised for is not asked about again. + assert_eq!( + gated(&st, &HashSet::from(["call_2"]), Some(&write_input)), + None + ); + // Two identical calls in flight: no telling which one is asking, + // unless one of them already had its card. st.apply_event(&minimax_tool_call("call_4", "in_progress", write)); - assert_eq!(gated(&st, Some(&write_input)), None); + assert_eq!(gated(&st, &unclaimed, Some(&write_input)), None); + assert_eq!( + gated(&st, &HashSet::from(["call_2"]), Some(&write_input)).as_deref(), + Some("call_4") + ); } /// The request can get here before the turn loop has applied the update - /// that announces the call it gates; the lookup waits for that call, and - /// gives up (keeping the agent's id) when none ever shows up. + /// that announces the call it gates; the lookup waits for that call (even + /// past an earlier, already-asked-about call with the same input that still + /// reads as in progress), and gives up, keeping the agent's id, when none + /// ever shows up. #[tokio::test(start_paused = true)] async fn a_minimax_code_permission_waits_briefly_for_the_call_it_gates() { let state = Arc::new(RwLock::new(SessionState::new( @@ -24070,34 +24122,65 @@ mod tests { "win".to_string(), None, ))); + let perms = PendingPermissions::default(); + let raw = r#"{"path":"/outside/w.txt","content":"x\n"}"#; let input = serde_json::json!({"path": "/outside/w.txt", "content": "x\n"}); - let late = tokio::spawn({ + let apply_later = |events: Vec| { let state = Arc::clone(&state); - async move { + tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(30)).await; - state.write().await.apply_event(&minimax_tool_call( - "call_9", - "in_progress", - r#"{"path":"/outside/w.txt","content":"x\n"}"#, - )); - } - }); + let mut guard = state.write().await; + for event in &events { + guard.apply_event(event); + } + }) + }; + + let late = apply_later(vec![minimax_tool_call("call_9", "in_progress", raw)]); assert_eq!( - await_minimax_gated_tool_call_id(&state, Some(&input)) + await_minimax_gated_tool_call_id(&perms, &state, Some(&input)) .await .as_deref(), Some("call_9") ); late.await.unwrap(); + // The same input again, for a newer call: call_9 was asked about and + // answered, its completion and the newer call's announcement are both + // still queued. call_9 still reads as in progress, but it is not the + // one asking. + state.write().await.apply_event(&AcpEvent::ToolApproval { + approval: ToolApprovalInfo { + tool_call_id: "call_9".to_string(), + option_kind: "allow_once".to_string(), + option_name: "Allow once".to_string(), + origin: "user".to_string(), + decided_at: chrono::Utc::now(), + }, + }); + let late = apply_later(vec![ + minimax_tool_call("call_9", "completed", raw), + minimax_tool_call("call_10", "in_progress", raw), + ]); + assert_eq!( + await_minimax_gated_tool_call_id(&perms, &state, Some(&input)) + .await + .as_deref(), + Some("call_10") + ); + late.await.unwrap(); + let started = tokio::time::Instant::now(); let other = serde_json::json!({"path": "/elsewhere.txt", "content": "y\n"}); - let gated = await_minimax_gated_tool_call_id(&state, Some(&other)).await; + let gated = await_minimax_gated_tool_call_id(&perms, &state, Some(&other)).await; assert_eq!(gated, None); assert!(started.elapsed() >= MINIMAX_GATED_CALL_WAIT); // A question asked through a permission card has no input to match. let started = tokio::time::Instant::now(); - assert_eq!(await_minimax_gated_tool_call_id(&state, None).await, None); + assert_eq!( + await_minimax_gated_tool_call_id(&perms, &state, None).await, + None + ); assert_eq!(started.elapsed(), std::time::Duration::ZERO); } From d3bf532ab49b4e1db4d5b0494bc4b94ff1470b74 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 11:08:51 +0800 Subject: [PATCH 11/11] fix(acp): remember MiniMax card matches for the turn Deriving already-asked-about calls from the permission queue and the approval log left gaps: a card withdrawn, drained or answered with an option it did not offer records no approval, and the 256-entry approval cap can evict a still-running call. Either let a later request with the same input match the stale call again. Record each match in SessionState::permission_matched_calls instead, under the same write lock as the lookup, and clear it with active_tool_calls. --- src-tauri/src/acp/connection.rs | 140 ++++++++++++++--------------- src-tauri/src/acp/session_state.rs | 9 ++ 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index de3aa159b12..6689a85b2f4 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -3290,14 +3290,6 @@ impl PermissionQueue { fn waiting_len(&self) -> usize { self.waiting.len() } - - /// The tool calls the unanswered cards (on screen or waiting) are about. - fn tool_call_ids(&self) -> impl Iterator { - self.responders - .values() - .map(|parked| parked.tool_call_id.as_str()) - .filter(|id| !id.is_empty()) - } } /// Shared per-connection permission state (responders + display queue). @@ -10624,18 +10616,19 @@ async fn handle_elicitation_request( /// /// Only a single unsettled call whose input equals the request's counts. The /// `_start_line` codeg adds to a live edit's input is ignored. A call in -/// `claimed` (one a card was already raised for) is never a candidate: an agent -/// asks about a call once, so a second request with the same input is about a -/// newer call, whose announcement may still be queued behind the earlier one's -/// completion. `None` (keep the agent's id) when nothing matches, or when two -/// identical calls are in flight and there is no telling which one is asking. +/// `matched` (one an earlier card this turn was already matched to) is never a +/// candidate: an agent asks about a call once, so a second request with the +/// same input is about a newer call, whose announcement may still be queued +/// behind the earlier one's completion. `None` (keep the agent's id) when +/// nothing matches, or when two identical calls are in flight and there is no +/// telling which one is asking. /// /// Still a heuristic: two identical calls run in parallel, asked about in the /// reverse of their announcement order while only the first is applied, swap /// cards. Their rows show the same input, so only the approval chips swap. fn minimax_gated_tool_call_id( active: &BTreeMap, - claimed: &HashSet<&str>, + matched: &BTreeSet, raw_input: Option<&serde_json::Value>, ) -> Option { use crate::acp::session_state::ToolCallStatus; @@ -10653,7 +10646,7 @@ fn minimax_gated_tool_call_id( matches!( call.status, ToolCallStatus::Pending | ToolCallStatus::InProgress - ) && !claimed.contains(call.id.as_str()) + ) && !matched.contains(&call.id) && call .input .as_ref() @@ -10672,33 +10665,35 @@ fn minimax_gated_tool_call_id( const MINIMAX_GATED_CALL_WAIT: std::time::Duration = std::time::Duration::from_millis(250); /// [`minimax_gated_tool_call_id`] against the live session state, waiting up -/// to [`MINIMAX_GATED_CALL_WAIT`] for the call to be applied. A request with no -/// `rawInput` (a question MiniMax Code asks through a permission card) gates -/// no call and does not wait. -/// -/// The claimed calls are the unanswered cards' plus the recorded approvals', -/// read under the queue lock: `resolve_permission` retires a card and records -/// its approval under that same lock, so a call is never in neither. -async fn await_minimax_gated_tool_call_id( - perms: &PendingPermissions, +/// to [`MINIMAX_GATED_CALL_WAIT`] for the call to be applied, and recording +/// the match in `SessionState::permission_matched_calls`. The record outlives +/// the card: withdrawn, drained or answered, the call was asked about. A +/// request with no `rawInput` (a question MiniMax Code asks through a +/// permission card) gates no call and does not wait. +/// +/// Lookup and record share one write lock, so two requests are never matched +/// to the same call. +async fn claim_minimax_gated_tool_call_id( state: &Arc>, raw_input: Option<&serde_json::Value>, ) -> Option { raw_input?; let deadline = tokio::time::Instant::now() + MINIMAX_GATED_CALL_WAIT; loop { - let gated = { - // Queue before state: the lock order `PermissionQueue` documents. - let queue = perms.lock().await; - let state = state.read().await; - let claimed: HashSet<&str> = queue - .tool_call_ids() - .chain(state.tool_approvals.iter().map(|a| a.tool_call_id.as_str())) - .collect(); - minimax_gated_tool_call_id(&state.active_tool_calls, &claimed, raw_input) - }; - if gated.is_some() || tokio::time::Instant::now() >= deadline { - return gated; + { + let mut state = state.write().await; + let gated = minimax_gated_tool_call_id( + &state.active_tool_calls, + &state.permission_matched_calls, + raw_input, + ); + if let Some(id) = gated { + state.permission_matched_calls.insert(id.clone()); + return Some(id); + } + } + if tokio::time::Instant::now() >= deadline { + return None; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -10776,8 +10771,7 @@ async fn handle_permission_request( // permission id (see `minimax_gated_tool_call_id`). if agent_type == AgentType::MiniMaxCode { let gated = - await_minimax_gated_tool_call_id(perms, state, req.tool_call.fields.raw_input.as_ref()) - .await; + claim_minimax_gated_tool_call_id(state, req.tool_call.fields.raw_input.as_ref()).await; if let (Some(id), Some(obj)) = (gated, tool_call_value.as_object_mut()) { obj.insert("toolCallId".to_string(), serde_json::Value::String(id)); } @@ -24069,10 +24063,11 @@ mod tests { "in_progress", r#"{"file_path":"/w/b.txt","old_string":"o","new_string":"n","_start_line":4}"#, )); - let unclaimed = HashSet::new(); + let unmatched = BTreeSet::new(); + let call_2_matched = BTreeSet::from(["call_2".to_string()]); let gated = - |st: &SessionState, claimed: &HashSet<&str>, input: Option<&serde_json::Value>| { - minimax_gated_tool_call_id(&st.active_tool_calls, claimed, input) + |st: &SessionState, matched: &BTreeSet, input: Option<&serde_json::Value>| { + minimax_gated_tool_call_id(&st.active_tool_calls, matched, input) }; let write_input = serde_json::json!({"path": "/w/a.txt", "content": "x\n"}); let edit_input = @@ -24081,36 +24076,33 @@ mod tests { // The settled call with the same input is not the one asking. assert_eq!( - gated(&st, &unclaimed, Some(&write_input)).as_deref(), + gated(&st, &unmatched, Some(&write_input)).as_deref(), Some("call_2") ); // The `_start_line` codeg adds to a live edit does not hide it. assert_eq!( - gated(&st, &unclaimed, Some(&edit_input)).as_deref(), + gated(&st, &unmatched, Some(&edit_input)).as_deref(), Some("call_3") ); // Nothing in flight with that input, or no input at all: keep the // agent's own id. - assert_eq!(gated(&st, &unclaimed, Some(&other_input)), None); - assert_eq!(gated(&st, &unclaimed, None), None); - // A call a card was already raised for is not asked about again. - assert_eq!( - gated(&st, &HashSet::from(["call_2"]), Some(&write_input)), - None - ); + assert_eq!(gated(&st, &unmatched, Some(&other_input)), None); + assert_eq!(gated(&st, &unmatched, None), None); + // A call an earlier card was matched to is not asked about again. + assert_eq!(gated(&st, &call_2_matched, Some(&write_input)), None); // Two identical calls in flight: no telling which one is asking, // unless one of them already had its card. st.apply_event(&minimax_tool_call("call_4", "in_progress", write)); - assert_eq!(gated(&st, &unclaimed, Some(&write_input)), None); + assert_eq!(gated(&st, &unmatched, Some(&write_input)), None); assert_eq!( - gated(&st, &HashSet::from(["call_2"]), Some(&write_input)).as_deref(), + gated(&st, &call_2_matched, Some(&write_input)).as_deref(), Some("call_4") ); } /// The request can get here before the turn loop has applied the update /// that announces the call it gates; the lookup waits for that call (even - /// past an earlier, already-asked-about call with the same input that still + /// past an earlier, already-matched call with the same input that still /// reads as in progress), and gives up, keeping the agent's id, when none /// ever shows up. #[tokio::test(start_paused = true)] @@ -24122,7 +24114,6 @@ mod tests { "win".to_string(), None, ))); - let perms = PendingPermissions::default(); let raw = r#"{"path":"/outside/w.txt","content":"x\n"}"#; let input = serde_json::json!({"path": "/outside/w.txt", "content": "x\n"}); let apply_later = |events: Vec| { @@ -24138,32 +24129,23 @@ mod tests { let late = apply_later(vec![minimax_tool_call("call_9", "in_progress", raw)]); assert_eq!( - await_minimax_gated_tool_call_id(&perms, &state, Some(&input)) + claim_minimax_gated_tool_call_id(&state, Some(&input)) .await .as_deref(), Some("call_9") ); late.await.unwrap(); - // The same input again, for a newer call: call_9 was asked about and - // answered, its completion and the newer call's announcement are both - // still queued. call_9 still reads as in progress, but it is not the - // one asking. - state.write().await.apply_event(&AcpEvent::ToolApproval { - approval: ToolApprovalInfo { - tool_call_id: "call_9".to_string(), - option_kind: "allow_once".to_string(), - option_name: "Allow once".to_string(), - origin: "user".to_string(), - decided_at: chrono::Utc::now(), - }, - }); + // The same input again, for a newer call. call_9's card may have been + // answered, withdrawn or drained, so no approval need be on record; its + // completion and the newer call's announcement are both still queued. + // call_9 still reads as in progress, but it is not the one asking. let late = apply_later(vec![ minimax_tool_call("call_9", "completed", raw), minimax_tool_call("call_10", "in_progress", raw), ]); assert_eq!( - await_minimax_gated_tool_call_id(&perms, &state, Some(&input)) + claim_minimax_gated_tool_call_id(&state, Some(&input)) .await .as_deref(), Some("call_10") @@ -24172,16 +24154,26 @@ mod tests { let started = tokio::time::Instant::now(); let other = serde_json::json!({"path": "/elsewhere.txt", "content": "y\n"}); - let gated = await_minimax_gated_tool_call_id(&perms, &state, Some(&other)).await; + let gated = claim_minimax_gated_tool_call_id(&state, Some(&other)).await; assert_eq!(gated, None); assert!(started.elapsed() >= MINIMAX_GATED_CALL_WAIT); // A question asked through a permission card has no input to match. let started = tokio::time::Instant::now(); + assert_eq!(claim_minimax_gated_tool_call_id(&state, None).await, None); + assert_eq!(started.elapsed(), std::time::Duration::ZERO); + + // The matches are per turn, like the calls they name. + let mut guard = state.write().await; assert_eq!( - await_minimax_gated_tool_call_id(&perms, &state, None).await, - None + guard.permission_matched_calls, + BTreeSet::from(["call_9".to_string(), "call_10".to_string()]) ); - assert_eq!(started.elapsed(), std::time::Duration::ZERO); + guard.apply_event(&AcpEvent::TurnComplete { + session_id: "s1".to_string(), + stop_reason: "end_turn".to_string(), + agent_type: "minimax_code".to_string(), + }); + assert!(guard.permission_matched_calls.is_empty()); } /// The card a MiniMax Code permission request raises, and the approval diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 0e8e2e2af97..c86f6876d77 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -284,6 +284,12 @@ pub struct SessionState { /// the elicitation handler before it replies, taken by the update that /// settles the call; per-turn, like `active_tool_calls`. pub qoder_ask_replies: BTreeMap>, + /// The tool calls codeg matched a permission card to itself, because the + /// agent's request named its own id instead of the call's (MiniMax Code, + /// see `connection::minimax_gated_tool_call_id`). An agent asks about a + /// call once, so a later request with the same input is about a newer call, + /// whatever became of the earlier card. Per-turn, like `active_tool_calls`. + pub permission_matched_calls: BTreeSet, pub pending_permission: Option, /// The agent's in-flight `ask_user_question` (one set of multiple-choice @@ -866,6 +872,7 @@ impl SessionState { live_message: None, active_tool_calls: BTreeMap::new(), qoder_ask_replies: BTreeMap::new(), + permission_matched_calls: BTreeSet::new(), pending_permission: None, pending_question: None, pending_plan_approval: None, @@ -1388,6 +1395,7 @@ impl SessionState { self.live_message = None; self.active_tool_calls.clear(); self.qoder_ask_replies.clear(); + self.permission_matched_calls.clear(); // The turn's user prompt is no longer "in flight" — the // assistant reply is done and the transcript is the source of // truth. Clear it so a post-turn snapshot doesn't carry a stale @@ -1813,6 +1821,7 @@ impl SessionState { self.live_message = None; self.active_tool_calls.clear(); self.qoder_ask_replies.clear(); + self.permission_matched_calls.clear(); true }

🧩 واجهة واحدة لكل الوكلاء
تسعة عشر وكيلاً مدمجًا، ويمكن لأي وكيل متوافق مع ACP الانضمام — ويُعرض عملهم جميعًا كمحادثة منظَّمة موحّدة، لا كطرفية.
🧩 واجهة واحدة لكل الوكلاء
عشرون وكيلاً مدمجًا، ويمكن لأي وكيل متوافق مع ACP الانضمام — ويُعرض عملهم جميعًا كمحادثة منظَّمة موحّدة، لا كطرفية.
🔎 جلسات تنتقل معك
استورد السجل الذي يحفظه كل وكيل على القرص، وابحث فيه، واستأنفه، ثم سلّمه إلى وكيل آخر.