From 41ce6716d0050c96c62033f92c3a9d7bc91ef997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Mon, 26 Oct 2026 12:28:09 +0800 Subject: [PATCH 1/8] fix(acp): bridge Antigravity / Gemini native ask_question into interactive question card (#1024) --- src-tauri/src/acp/connection.rs | 142 ++++++++++++++++ src-tauri/src/acp/question.rs | 290 ++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d6bb5b44af..7d5b17e88b 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -7803,6 +7803,23 @@ async fn run_connection( Ok(()) => return Ok(()), Err(responder) => responder, }; + // Antigravity / Gemini asks the user a question through native + // `ask_question` / `default_api:ask_question` (#1024). Route it + // to the interactive question card instead of an approval card. + let responder = match try_bridge_antigravity_ask( + &perm_ask_access, + &perm_conn_id, + &state_inner, + &emitter_inner, + &perms, + &req, + responder, + ) + .await + { + Ok(()) => return Ok(()), + Err(responder) => responder, + }; handle_permission_request( &cx, &state_inner, @@ -9703,6 +9720,131 @@ async fn try_bridge_pi_select_ask( Ok(()) } +/// Bridge Google Antigravity / Gemini native `ask_question` tool calls into codeg's +/// interactive question card (#1024). +/// +/// When Google Antigravity or Gemini CLI calls its native `ask_question` tool, +/// the agent process initiates a `session/request_permission` before executing it. +/// Because it lacks the `codeg-mcp` prefix, it was previously treated as an approval +/// card ("Allow Once / Allow Always / Deny"). This bridge recognizes the native ask +/// tool, registers the question payload into [`crate::acp::question::SessionQuestionAccess`], +/// renders the in-stream answered capsule upon completion, and replies to the +/// permission request with `allow_once` (or `cancelled` if declined/aborted). +#[allow(clippy::result_large_err)] +async fn try_bridge_antigravity_ask( + access: &Option<( + Arc, + crate::acp::question::QuestionRuntimeConfig, + )>, + connection_id: &str, + state: &Arc>, + emitter: &EventEmitter, + perms: &PendingPermissions, + req: &RequestPermissionRequest, + responder: Responder, +) -> Result<(), Responder> { + let title = [ + req.tool_call.fields.title.as_deref(), + crate::acp::air_contract::permission_record(req.meta.as_ref()) + .and_then(|p| p.get("title")) + .and_then(serde_json::Value::as_str), + ] + .into_iter() + .flatten() + .next() + .unwrap_or_default(); + + if !crate::acp::question::is_antigravity_ask_tool_name(title) { + return Err(responder); + } + + let Some((questions, ask_cfg)) = access else { + return Err(responder); + }; + if !ask_cfg.is_enabled().await { + return Err(responder); + } + + let tool_call = serde_json::to_value(&req.tool_call).unwrap_or_default(); + let Some(ask) = crate::acp::question::parse_antigravity_ask(&tool_call) else { + return Err(responder); + }; + + let allow_option_id = req + .options + .iter() + .find(|opt| opt.kind == PermissionOptionKind::AllowOnce) + .map(|opt| opt.option_id.to_string()) + .or_else(|| req.options.first().map(|opt| opt.option_id.to_string())); + + let (abort_tx, abort_rx) = oneshot::channel(); + perms.lock().await.park_detached(abort_tx); + + let Some(registered) = questions + .register_question(connection_id, ask.specs.clone()) + .await + else { + return Err(responder); + }; + + let state = Arc::clone(state); + let emitter = emitter.clone(); + let questions = Arc::clone(questions); + let connection_id = connection_id.to_string(); + let question_id = registered.question_id; + let tool_call_id = req.tool_call.tool_call_id.to_string(); + + tokio::spawn(async move { + let answer = tokio::select! { + biased; + answered = registered.answer_rx => answered.ok(), + _ = abort_rx => None, + }; + match answer { + Some(outcome) => { + emit_with_state( + &state, + &emitter, + AcpEvent::ToolCall { + tool_call_id, + title: "ask_user_question".to_string(), + kind: "other".to_string(), + status: "completed".to_string(), + content: None, + raw_input: Some( + crate::acp::question::grok_result_card_input(&ask.specs).to_string(), + ), + raw_output: Some( + crate::acp::question::grok_result_card_output(&outcome).to_string(), + ), + locations: None, + meta: None, + images: None, + }, + ) + .await; + + let response_outcome = if outcome.declined { + RequestPermissionOutcome::Cancelled + } else if let Some(opt_id) = allow_option_id { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(opt_id)) + } else { + RequestPermissionOutcome::Cancelled + }; + let _ = responder.respond(RequestPermissionResponse::new(response_outcome)); + } + None => { + questions.cancel_question(&connection_id, &question_id).await; + let _ = responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )); + } + } + }); + + Ok(()) +} + /// Bridge grok's native `_x.ai/exit_plan_mode` ext request into codeg's /// interactive plan-approval card. Grok BLOCKS on the reply — it won't leave plan /// mode until the user acts — so codeg registers the approval through the shared diff --git a/src-tauri/src/acp/question.rs b/src-tauri/src/acp/question.rs index 390b820068..82a85654a4 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -701,6 +701,175 @@ pub fn is_codeg_ask_tool_name(name: &str) -> bool { crate::acp::delegation::is_codeg_mcp_tool(name, "ask_user_question") } +/// True when a host's name for a tool is Google Antigravity or Gemini CLI's +/// native `ask_question` tool (`ask_question`, `default_api:ask_question`, +/// `default_api/ask_question`, `default_api__ask_question`, etc.). +pub fn is_antigravity_ask_tool_name(name: &str) -> bool { + let name = name.trim().to_ascii_lowercase(); + if name == "ask_question" { + return true; + } + const PREFIXES: [&str; 9] = [ + "default_api:", + "default_api::", + "default_api/", + "default_api__", + "default_api.", + "antigravity:", + "antigravity/", + "antigravity__", + "gemini:", + ]; + for prefix in PREFIXES { + if let Some(rest) = name.strip_prefix(prefix) { + if rest.trim() == "ask_question" { + return true; + } + } + } + false +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AntigravityAsk { + pub specs: Vec, +} + +/// Parse Google Antigravity / Gemini native `ask_question` tool call arguments into +/// interactive [`QuestionSpec`]s. +pub fn parse_antigravity_ask(tool_call: &Value) -> Option { + if let Some(title) = tool_call + .get("title") + .and_then(|v| v.as_str()) + .or_else(|| { + tool_call + .get("fields") + .and_then(|f| f.get("title")) + .and_then(|v| v.as_str()) + }) + { + if !is_antigravity_ask_tool_name(title) { + return None; + } + } + + let raw_input = tool_call + .get("rawInput") + .or_else(|| tool_call.get("raw_input")) + .or_else(|| tool_call.get("arguments")) + .or_else(|| tool_call.get("args")) + .or_else(|| { + tool_call + .get("fields") + .and_then(|f| f.get("rawInput").or_else(|| f.get("raw_input"))) + })?; + + let input_value: Value = if let Some(s) = raw_input.as_str() { + serde_json::from_str(s).ok()? + } else { + raw_input.clone() + }; + + let target_obj = if let Some(args) = input_value.get("arguments").and_then(|v| v.as_object()) { + args + } else if let Some(obj) = input_value.as_object() { + obj + } else { + return None; + }; + + let questions_val = target_obj.get("questions")?; + let questions_arr: Vec = if let Some(arr) = questions_val.as_array() { + arr.clone() + } else if let Some(s) = questions_val.as_str() { + serde_json::from_str(s).ok()? + } else { + return None; + }; + + if !(1..=MAX_QUESTIONS).contains(&questions_arr.len()) { + return None; + } + + let mut specs = Vec::with_capacity(questions_arr.len()); + for q in &questions_arr { + let question_text = q + .get("question") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty())?; + if question_text.chars().count() > MAX_QUESTION_TEXT_CHARS { + return None; + } + + let header = q + .get("header") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter(|s| s.chars().count() <= MAX_HEADER_CHARS) + .map(|s| s.to_string()) + .unwrap_or_else(|| synthesize_header(question_text)); + + let multi_select = q + .get("is_multi_select") + .or_else(|| q.get("isMultiSelect")) + .or_else(|| q.get("multi_select")) + .or_else(|| q.get("multiSelect")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let opts = q.get("options").and_then(|v| v.as_array())?; + if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&opts.len()) { + return None; + } + + let mut options = Vec::with_capacity(opts.len()); + let mut seen = std::collections::HashSet::new(); + for opt in opts { + let label = if let Some(s) = opt.as_str() { + s.trim() + } else if let Some(label_str) = opt.get("label").and_then(|v| v.as_str()) { + label_str.trim() + } else if let Some(text_str) = opt.get("text").and_then(|v| v.as_str()) { + text_str.trim() + } else { + return None; + }; + + if label.is_empty() || label.chars().count() > MAX_QUESTION_TEXT_CHARS { + return None; + } + if !seen.insert(label) { + return None; + } + + let description = opt + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + options.push(QuestionOption { + label: label.to_string(), + description, + }); + } + + specs.push(QuestionSpec { + id: uuid::Uuid::new_v4().to_string(), + question: question_text.to_string(), + header, + multi_select, + options, + is_secret: false, + }); + } + + Some(AntigravityAsk { specs }) +} + /// Serialize a resolved [`QuestionOutcome`] into grok's `AskUserQuestionExtResponse` /// — the reply to a `_x.ai/ask_user_question` ext request. Verified against grok /// 0.2.101 on a real run: the response is internally tagged by `outcome`; the @@ -3615,4 +3784,125 @@ mod tests { assert_eq!(input["questions"][0]["question"], output["answers"][0]["question"]); assert_eq!(output["answers"][0]["header"], ""); } + #[test] + fn is_antigravity_ask_tool_name_accepts_valid_names() { + for valid in [ + "ask_question", + "default_api:ask_question", + "default_api::ask_question", + "default_api/ask_question", + "default_api__ask_question", + "default_api.ask_question", + "default_api: ask_question", + "antigravity:ask_question", + "antigravity/ask_question", + "gemini:ask_question", + " DEFAULT_API:ASK_QUESTION ", + "ASK_QUESTION", + ] { + assert!( + is_antigravity_ask_tool_name(valid), + "{valid} must be recognized as antigravity ask tool" + ); + } + } + + #[test] + fn is_antigravity_ask_tool_name_rejects_other_tools() { + for other in [ + "mcp__codeg-mcp__ask_user_question", + "ask_user_question", + "default_api:run_command", + "default_api:view_file", + "other_api:ask_question", + "ask_question_extra", + "", + ] { + assert!( + !is_antigravity_ask_tool_name(other), + "{other} must NOT be recognized as antigravity ask tool" + ); + } + } + + #[test] + fn parse_antigravity_ask_maps_native_payload() { + let tool_call = json!({ + "title": "default_api:ask_question", + "rawInput": { + "questions": [ + { + "question": "Which architecture pattern do you prefer?", + "options": ["Option A (Clean)", "Option B (Layered)"], + "is_multi_select": false + } + ] + } + }); + let ask = parse_antigravity_ask(&tool_call).expect("should parse"); + assert_eq!(ask.specs.len(), 1); + assert_eq!(ask.specs[0].question, "Which architecture pattern do you prefer?"); + assert!(!ask.specs[0].multi_select); + assert_eq!(ask.specs[0].options.len(), 2); + assert_eq!(ask.specs[0].options[0].label, "Option A (Clean)"); + assert_eq!(ask.specs[0].options[1].label, "Option B (Layered)"); + assert!(!ask.specs[0].header.is_empty()); + } + + #[test] + fn parse_antigravity_ask_handles_serialized_questions_string() { + let questions_json_str = serde_json::to_string(&json!([ + { + "question": "Select target environments:", + "options": [ + {"label": "Staging", "description": "Pre-prod"}, + {"label": "Production", "description": "Live"} + ], + "is_multi_select": true + } + ])).unwrap(); + + let tool_call = json!({ + "title": "ask_question", + "rawInput": { + "arguments": { + "questions": questions_json_str + } + } + }); + let ask = parse_antigravity_ask(&tool_call).expect("should parse nested string"); + assert_eq!(ask.specs.len(), 1); + assert_eq!(ask.specs[0].question, "Select target environments:"); + assert!(ask.specs[0].multi_select); + assert_eq!(ask.specs[0].options[0].label, "Staging"); + assert_eq!(ask.specs[0].options[0].description, "Pre-prod"); + assert_eq!(ask.specs[0].options[1].label, "Production"); + } + + #[test] + fn parse_antigravity_ask_declines_invalid_shapes() { + // Less than MIN_OPTIONS + assert!(parse_antigravity_ask(&json!({ + "title": "ask_question", + "rawInput": { + "questions": [{ "question": "Q?", "options": ["Only one"] }] + } + })).is_none()); + + // Empty question text + assert!(parse_antigravity_ask(&json!({ + "title": "ask_question", + "rawInput": { + "questions": [{ "question": " ", "options": ["A", "B"] }] + } + })).is_none()); + + // Duplicate options + assert!(parse_antigravity_ask(&json!({ + "title": "ask_question", + "rawInput": { + "questions": [{ "question": "Q?", "options": ["Same", "Same"] }] + } + })).is_none()); + } } From f3ad5ad5da3131106473ac98080155d11385b041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Mon, 26 Oct 2026 12:53:22 +0800 Subject: [PATCH 2/8] docs: add Windows MSVC rlib limitation and agent guidelines to AGENTS.md --- AGENTS.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 52d68d4236..591bcd2f9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,51 @@ cargo insta review INSTA_UPDATE=auto cargo test --features test-utils # 自动写新 .snap ``` +## 常见构建问题与避坑指南 (Troubleshooting) + +### 1. Windows MSVC 4GB `.rlib` 链接器限制 (LNK4003 / LNK2019 / LNK1120) + +- **现象**: + 在 Windows MSVC 环境下构建或测试(如 `cargo build --bin codeg`)时报错: + ```text + warning: LNK4003: 无效的库格式;已忽略库 '...\\libcodeg_lib-xxx.rlib' + error: linking with `link.exe` failed: exit code: 1120 + ... + libcodeg_lib-xxx.rlib : error LNK2019: 无法解析的外部符号 ... + ``` +- **根本原因**: + Rust 默认开发配置使用 `debug = 2` 生成完整 AST 和变量调试信息。项目依赖较多时,核心库 `libcodeg_lib.rlib` 体积会膨胀超过 4GB(例如 4.27 GB)。MSVC 的 COFF/PE 归档格式使用 32 位偏移寻址,超出 4GB 限制导致归档损坏,`link.exe` 判定格式无效而跳过链接,引发连锁未解析符号错误。 +- **解决方案(推荐方案)**: + 将 `debuginfo` 级别设为 1(保留文件名与行号,保持完整 panic 堆栈回溯,但不内嵌庞大的 AST 符号信息): + ```powershell + # 1. 设置环境变量(当前终端生效) + $env:RUSTFLAGS = "-C debuginfo=1" + + # 2. 删除已经损坏超过 4GB 的产物 + Remove-Item "src-tauri\target\debug\deps\*codeg_lib*" -Force -ErrorAction SilentlyContinue + + # 3. 重新构建 + cargo build --bin codeg + ``` + 设置 `debuginfo=1` 后,`libcodeg_lib.rlib` 体积会从 4.2GB+ 骤降至 200~300MB,链接顺利通过。 + 持久化方案亦可在 `src-tauri/.cargo/config.toml` 中配置: + ```toml + [profile.dev] + debug = 1 + ``` + +### 2. Git Worktree 与多 Agent 工作区隔离规范 + +- 为避免并行任务互相干扰,在多工作区(如 `worktree`)工作时: + 1. 必须始终在指定的物理工作目录执行文件读写与终端命令(禁止跨越至其他目录)。 + 2. 禁止在独立工作区切换共享分支或破坏其他工作区的构建缓存。 + 3. 若新建工作区缺少静态资源输出目录,Tauri 构建脚本可能检查 `..\out`,需确保 `out/` 占位目录存在。 + +### 3. Agent Client Protocol (ACP) 提问工具桥接 + +- Antigravity / Gemini CLI 原生提问工具(`ask_question` / `default_api:ask_question`)与标准 MCP 权限请求不同。 +- `src-tauri/src/acp/` 模块中提供了 `try_bridge_antigravity_ask` 机制,将其转化为交互式问题卡片并在前端呈现,而不是误当作普通工具执行权限请求拦截。后续扩展智能体原生交互协议时需遵循此桥接流程。 + ## 架构 ### 双模式运行 From 794c37d77a56b14590d781e3e2448742d03ed2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Mon, 26 Oct 2026 13:34:22 +0800 Subject: [PATCH 3/8] fix(acp): satisfy question_mark clippy lint in parse_antigravity_ask --- src-tauri/src/acp/question.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/acp/question.rs b/src-tauri/src/acp/question.rs index 82a85654a4..2756150d79 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -772,19 +772,16 @@ pub fn parse_antigravity_ask(tool_call: &Value) -> Option { let target_obj = if let Some(args) = input_value.get("arguments").and_then(|v| v.as_object()) { args - } else if let Some(obj) = input_value.as_object() { - obj } else { - return None; + input_value.as_object()? }; let questions_val = target_obj.get("questions")?; let questions_arr: Vec = if let Some(arr) = questions_val.as_array() { arr.clone() - } else if let Some(s) = questions_val.as_str() { - serde_json::from_str(s).ok()? } else { - return None; + let s = questions_val.as_str()?; + serde_json::from_str(s).ok()? }; if !(1..=MAX_QUESTIONS).contains(&questions_arr.len()) { @@ -831,10 +828,9 @@ pub fn parse_antigravity_ask(tool_call: &Value) -> Option { s.trim() } else if let Some(label_str) = opt.get("label").and_then(|v| v.as_str()) { label_str.trim() - } else if let Some(text_str) = opt.get("text").and_then(|v| v.as_str()) { - text_str.trim() } else { - return None; + let text_str = opt.get("text").and_then(|v| v.as_str())?; + text_str.trim() }; if label.is_empty() || label.chars().count() > MAX_QUESTION_TEXT_CHARS { From 9ff842c353788cccdef7ebf2887b5c801321b385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Tue, 27 Oct 2026 01:07:12 +0800 Subject: [PATCH 4/8] fix(acp): bridge Antigravity native ask_question and restore chosen options in history (#1024) --- src-tauri/src/acp/connection.rs | 293 +++++++++++++--- src-tauri/src/acp/question.rs | 407 +++++++++-------------- src-tauri/src/parsers/antigravity.rs | 134 ++++++++ src/lib/adapters/tool-kind-classifier.ts | 3 + src/lib/ask-question.ts | 27 +- src/lib/tool-call-normalization.ts | 3 + 6 files changed, 557 insertions(+), 310 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7d5b17e88b..1b3f2d622e 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -9720,16 +9720,7 @@ async fn try_bridge_pi_select_ask( Ok(()) } -/// Bridge Google Antigravity / Gemini native `ask_question` tool calls into codeg's -/// interactive question card (#1024). -/// -/// When Google Antigravity or Gemini CLI calls its native `ask_question` tool, -/// the agent process initiates a `session/request_permission` before executing it. -/// Because it lacks the `codeg-mcp` prefix, it was previously treated as an approval -/// card ("Allow Once / Allow Always / Deny"). This bridge recognizes the native ask -/// tool, registers the question payload into [`crate::acp::question::SessionQuestionAccess`], -/// renders the in-stream answered capsule upon completion, and replies to the -/// permission request with `allow_once` (or `cancelled` if declined/aborted). +/// 将 Antigravity 原生 interaction_* 问答接入共享卡片,回传选中的原始 optionId。 #[allow(clippy::result_large_err)] async fn try_bridge_antigravity_ask( access: &Option<( @@ -9743,56 +9734,102 @@ async fn try_bridge_antigravity_ask( req: &RequestPermissionRequest, responder: Responder, ) -> Result<(), Responder> { - let title = [ - req.tool_call.fields.title.as_deref(), - crate::acp::air_contract::permission_record(req.meta.as_ref()) - .and_then(|p| p.get("title")) - .and_then(serde_json::Value::as_str), - ] - .into_iter() - .flatten() - .next() - .unwrap_or_default(); - - if !crate::acp::question::is_antigravity_ask_tool_name(title) { + if state.read().await.agent_type != AgentType::Antigravity { return Err(responder); } - let Some((questions, ask_cfg)) = access else { + let tool_call_id = req.tool_call.tool_call_id.to_string(); + let tool_call = serde_json::to_value(&req.tool_call).unwrap_or_default(); + let options: Vec<_> = req + .options + .iter() + .map(|opt| (opt.option_id.to_string(), opt.name.clone())) + .collect(); + let Some((questions, _ask_cfg)) = access else { + if tool_call_id.starts_with("interaction_") { + tracing::warn!( + "[ACP] Antigravity interaction bridge has no question access conn={} tool_call_id={}", + connection_id, + tool_call_id + ); + } return Err(responder); }; - if !ask_cfg.is_enabled().await { - return Err(responder); - } - let tool_call = serde_json::to_value(&req.tool_call).unwrap_or_default(); - let Some(ask) = crate::acp::question::parse_antigravity_ask(&tool_call) else { + let Some(ask) = crate::acp::question::parse_antigravity_ask(&tool_call, &options) else { + let id_matches = tool_call + .get("toolCallId") + .and_then(serde_json::Value::as_str) + .is_some_and(|id| id.starts_with("interaction_") && id.len() > 12); + if id_matches { + let title_present = tool_call + .get("title") + .and_then(serde_json::Value::as_str) + .is_some_and(|title| !title.trim().is_empty()); + let kind = tool_call + .get("kind") + .and_then(serde_json::Value::as_str) + .unwrap_or("missing"); + let status = tool_call + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("missing"); + let raw_input_empty_object = tool_call + .get("rawInput") + .and_then(serde_json::Value::as_object) + .is_some_and(serde_json::Map::is_empty); + let mut seen_ids = std::collections::HashSet::new(); + let mut seen_names = std::collections::HashSet::new(); + let options_valid = options.iter().all(|(id, name)| { + !id.trim().is_empty() + && !name.trim().is_empty() + && name.chars().count() <= crate::acp::question::MAX_QUESTION_TEXT_CHARS + && seen_ids.insert(id) + && seen_names.insert(name.trim()) + }); + tracing::warn!( + "[ACP] Antigravity interaction bridge rejected request conn={} tool_call_id={} id_matches={} title_present={} kind={} status={} raw_input_empty_object={} option_count={} options_valid={}", + connection_id, + tool_call_id, + id_matches, + title_present, + kind, + status, + raw_input_empty_object, + options.len(), + options_valid + ); + } return Err(responder); }; - let allow_option_id = req - .options - .iter() - .find(|opt| opt.kind == PermissionOptionKind::AllowOnce) - .map(|opt| opt.option_id.to_string()) - .or_else(|| req.options.first().map(|opt| opt.option_id.to_string())); - let (abort_tx, abort_rx) = oneshot::channel(); perms.lock().await.park_detached(abort_tx); let Some(registered) = questions - .register_question(connection_id, ask.specs.clone()) + .register_question(connection_id, vec![ask.spec.clone()]) .await else { + tracing::warn!( + "[ACP] Antigravity interaction bridge could not register question conn={} tool_call_id={}", + connection_id, + tool_call_id + ); return Err(responder); }; + tracing::info!( + "[ACP] Antigravity interaction bridged conn={} tool_call_id={} option_count={}", + connection_id, + tool_call_id, + ask.spec.options.len() + ); + let state = Arc::clone(state); let emitter = emitter.clone(); let questions = Arc::clone(questions); let connection_id = connection_id.to_string(); let question_id = registered.question_id; - let tool_call_id = req.tool_call.tool_call_id.to_string(); tokio::spawn(async move { let answer = tokio::select! { @@ -9802,6 +9839,15 @@ async fn try_bridge_antigravity_ask( }; match answer { Some(outcome) => { + let option_id = crate::acp::question::antigravity_option_id(&outcome, &ask); + let recorded = if option_id.is_some() { + outcome + } else { + crate::acp::question::QuestionOutcome { + answers: Vec::new(), + declined: true, + } + }; emit_with_state( &state, &emitter, @@ -9812,10 +9858,13 @@ async fn try_bridge_antigravity_ask( status: "completed".to_string(), content: None, raw_input: Some( - crate::acp::question::grok_result_card_input(&ask.specs).to_string(), + crate::acp::question::grok_result_card_input(std::slice::from_ref( + &ask.spec, + )) + .to_string(), ), raw_output: Some( - crate::acp::question::grok_result_card_output(&outcome).to_string(), + crate::acp::question::grok_result_card_output(&recorded).to_string(), ), locations: None, meta: None, @@ -9824,17 +9873,18 @@ async fn try_bridge_antigravity_ask( ) .await; - let response_outcome = if outcome.declined { - RequestPermissionOutcome::Cancelled - } else if let Some(opt_id) = allow_option_id { - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(opt_id)) - } else { - RequestPermissionOutcome::Cancelled + let response_outcome = match option_id { + Some(id) => { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)) + } + None => RequestPermissionOutcome::Cancelled, }; let _ = responder.respond(RequestPermissionResponse::new(response_outcome)); } None => { - questions.cancel_question(&connection_id, &question_id).await; + questions + .cancel_question(&connection_id, &question_id) + .await; let _ = responder.respond(RequestPermissionResponse::new( RequestPermissionOutcome::Cancelled, )); @@ -41751,6 +41801,157 @@ mod tests { ); } + struct AntigravityTestAnswer { + decline: bool, + } + + #[async_trait::async_trait] + impl crate::acp::question::SessionQuestionAccess for AntigravityTestAnswer { + async fn register_question( + &self, + _connection_id: &str, + specs: Vec, + ) -> Option { + assert_eq!(specs.len(), 1); + let q = &specs[0]; + let (tx, rx) = oneshot::channel(); + let _ = tx.send(crate::acp::question::QuestionOutcome { + declined: self.decline, + answers: vec![crate::acp::question::QuestionAnsweredItem { + question: q.question.clone(), + header: q.header.clone(), + multi_select: false, + selected: vec![q.options[1].label.clone()], + }], + }); + Some(crate::acp::question::RegisteredQuestion { + question_id: q.id.clone(), + answer_rx: rx, + }) + } + async fn cancel_question(&self, _connection_id: &str, _question_id: &str) {} + async fn cancel_questions_by_parent(&self, _connection_id: &str) {} + } + + #[tokio::test] + async fn antigravity_interaction_round_trip_preserves_choice_and_gates() { + use agent_client_protocol::Channel; + for (agent_type, enabled, decline, bridged) in [ + (AgentType::Antigravity, true, false, true), + (AgentType::Antigravity, true, true, true), + (AgentType::Antigravity, false, false, true), + (AgentType::Codex, true, false, false), + ] { + let (client_end, agent_end) = Channel::duplex(); + let state = Arc::new(RwLock::new(SessionState::new( + "conn-test".into(), + agent_type, + None, + "win".into(), + None, + ))); + let config = crate::acp::question::QuestionRuntimeConfig::new(); + config + .set(crate::acp::question::QuestionConfig { enabled }) + .await; + let access: Option<(Arc, _)> = + Some((Arc::new(AntigravityTestAnswer { decline }), config)); + let agent = tokio::spawn(Agent.builder().connect_with( + agent_end, + async move |cx: ConnectionTo| { + // Two native questions arrive separately on the same connection. + for i in 0..2 { + let mut tool_call_json = serde_json::json!({ + "toolCallId": format!("interaction_{i}"), + "title": format!("Question {i}?"), + "status": "pending", + "rawInput": {}, + }); + if i == 1 { + tool_call_json["kind"] = serde_json::json!("other"); + } + let request: RequestPermissionRequest = serde_json::from_value( + serde_json::json!({ + "sessionId": "s1", + "toolCall": tool_call_json, + "options": [ + {"optionId": "opaque-first", "name": "Backend", "kind": "allow_once"}, + {"optionId": "opaque-second", "name": "Frontend", "kind": "allow_once"}, + ], + }) + ).unwrap(); + let reply = cx.send_request_to(Client, request).block_task().await?; + if bridged && !decline { + assert!(matches!(reply.outcome, + RequestPermissionOutcome::Selected(ref selected) + if selected.option_id.to_string() == "opaque-second")); + } else { + assert!(matches!(reply.outcome, RequestPermissionOutcome::Cancelled)); + } + } + Ok::<(), agent_client_protocol::Error>(()) + }, + )); + let client = tokio::spawn({ + let state = Arc::clone(&state); + let perms = PendingPermissions::default(); + Client + .builder() + .on_receive_request( + async move |req: RequestPermissionRequest, + responder: Responder, + _cx: ConnectionTo| { + if let Err(responder) = try_bridge_antigravity_ask( + &access, + "conn-test", + &state, + &EventEmitter::Noop, + &perms, + &req, + responder, + ) + .await + { + assert!(!bridged, "native interaction unexpectedly fell through"); + responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + ))?; + } else { + assert!(bridged, "ordinary permission unexpectedly bridged"); + } + Ok(()) + }, + on_receive_request!(), + ) + .connect_with(client_end, async |_cx: ConnectionTo| { + std::future::pending::>().await + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(5), agent) + .await + .expect("native request must receive its answer") + .unwrap() + .unwrap(); + client.abort(); + let state = state.read().await; + let events = state.recent_events_after(0).unwrap_or_default(); + let cards: Vec<_> = events + .iter() + .filter_map(|event| match &event.payload { + AcpEvent::ToolCall { + title, raw_output, .. + } if title == "ask_user_question" => Some(raw_output.as_ref().unwrap()), + _ => None, + }) + .collect(); + assert_eq!(cards.len(), if bridged { 2 } else { 0 }); + for card in cards { + let output: serde_json::Value = serde_json::from_str(card).unwrap(); + assert_eq!(output["declined"], decline); + } + } + } + /// Answers every question with its first option the moment it is asked. struct AnswersFirstOption; diff --git a/src-tauri/src/acp/question.rs b/src-tauri/src/acp/question.rs index 2756150d79..f67dc48ab2 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -701,169 +701,94 @@ pub fn is_codeg_ask_tool_name(name: &str) -> bool { crate::acp::delegation::is_codeg_mcp_tool(name, "ask_user_question") } -/// True when a host's name for a tool is Google Antigravity or Gemini CLI's -/// native `ask_question` tool (`ask_question`, `default_api:ask_question`, -/// `default_api/ask_question`, `default_api__ask_question`, etc.). -pub fn is_antigravity_ask_tool_name(name: &str) -> bool { - let name = name.trim().to_ascii_lowercase(); - if name == "ask_question" { - return true; - } - const PREFIXES: [&str; 9] = [ - "default_api:", - "default_api::", - "default_api/", - "default_api__", - "default_api.", - "antigravity:", - "antigravity/", - "antigravity__", - "gemini:", - ]; - for prefix in PREFIXES { - if let Some(rest) = name.strip_prefix(prefix) { - if rest.trim() == "ask_question" { - return true; - } - } - } - false -} - +/// Antigravity 原生交互通过权限选项承载答案,保留文字到 optionId 的映射。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AntigravityAsk { - pub specs: Vec, + pub spec: QuestionSpec, + pub option_ids: Vec<(String, String)>, } -/// Parse Google Antigravity / Gemini native `ask_question` tool call arguments into -/// interactive [`QuestionSpec`]s. -pub fn parse_antigravity_ask(tool_call: &Value) -> Option { - if let Some(title) = tool_call - .get("title") - .and_then(|v| v.as_str()) - .or_else(|| { - tool_call - .get("fields") - .and_then(|f| f.get("title")) - .and_then(|v| v.as_str()) - }) +/// 识别 antigravity-acp 1.4.1 的交互请求,而非模型调用 ask_question 的参数。 +/// 实测 title 是问题正文、rawInput 为空,选项位于请求外层。 +/// 调用方还必须校验 AgentType::Antigravity,避免接管其他代理的权限请求。 +pub fn parse_antigravity_ask( + tool_call: &Value, + options: &[(String, String)], +) -> Option { + let id = tool_call.get("toolCallId")?.as_str()?; + if id.strip_prefix("interaction_")?.is_empty() + || !(MIN_OPTIONS..=MAX_OPTIONS).contains(&options.len()) { - if !is_antigravity_ask_tool_name(title) { + return None; + } + if let Some(kind) = tool_call.get("kind").and_then(Value::as_str) { + if kind != "other" { return None; } } - - let raw_input = tool_call - .get("rawInput") - .or_else(|| tool_call.get("raw_input")) - .or_else(|| tool_call.get("arguments")) - .or_else(|| tool_call.get("args")) - .or_else(|| { - tool_call - .get("fields") - .and_then(|f| f.get("rawInput").or_else(|| f.get("raw_input"))) - })?; - - let input_value: Value = if let Some(s) = raw_input.as_str() { - serde_json::from_str(s).ok()? - } else { - raw_input.clone() - }; - - let target_obj = if let Some(args) = input_value.get("arguments").and_then(|v| v.as_object()) { - args - } else { - input_value.as_object()? - }; - - let questions_val = target_obj.get("questions")?; - let questions_arr: Vec = if let Some(arr) = questions_val.as_array() { - arr.clone() - } else { - let s = questions_val.as_str()?; - serde_json::from_str(s).ok()? - }; - - if !(1..=MAX_QUESTIONS).contains(&questions_arr.len()) { - return None; - } - - let mut specs = Vec::with_capacity(questions_arr.len()); - for q in &questions_arr { - let question_text = q - .get("question") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty())?; - if question_text.chars().count() > MAX_QUESTION_TEXT_CHARS { + if let Some(status) = tool_call.get("status").and_then(Value::as_str) { + if status != "pending" { return None; } - - let header = q - .get("header") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .filter(|s| s.chars().count() <= MAX_HEADER_CHARS) - .map(|s| s.to_string()) - .unwrap_or_else(|| synthesize_header(question_text)); - - let multi_select = q - .get("is_multi_select") - .or_else(|| q.get("isMultiSelect")) - .or_else(|| q.get("multi_select")) - .or_else(|| q.get("multiSelect")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let opts = q.get("options").and_then(|v| v.as_array())?; - if !(MIN_OPTIONS..=MAX_OPTIONS).contains(&opts.len()) { + } + if let Some(raw_input) = tool_call.get("rawInput") { + let is_empty_or_null = raw_input.is_null() + || raw_input.as_object().is_some_and(serde_json::Map::is_empty); + if !is_empty_or_null { return None; } - - let mut options = Vec::with_capacity(opts.len()); - let mut seen = std::collections::HashSet::new(); - for opt in opts { - let label = if let Some(s) = opt.as_str() { - s.trim() - } else if let Some(label_str) = opt.get("label").and_then(|v| v.as_str()) { - label_str.trim() - } else { - let text_str = opt.get("text").and_then(|v| v.as_str())?; - text_str.trim() - }; - - if label.is_empty() || label.chars().count() > MAX_QUESTION_TEXT_CHARS { - return None; - } - if !seen.insert(label) { - return None; - } - - let description = opt - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - - options.push(QuestionOption { - label: label.to_string(), - description, - }); + } + let question = tool_call.get("title")?.as_str()?.trim(); + if question.is_empty() || question.chars().count() > MAX_QUESTION_TEXT_CHARS { + return None; + } + let mut choices = Vec::with_capacity(options.len()); + let mut option_ids = Vec::with_capacity(options.len()); + let mut labels = std::collections::HashSet::new(); + let mut ids = std::collections::HashSet::new(); + for (id, name) in options { + let label = name.trim(); + if id.trim().is_empty() + || label.is_empty() + || label.chars().count() > MAX_QUESTION_TEXT_CHARS + || !labels.insert(label) + || !ids.insert(id) + { + return None; } - - specs.push(QuestionSpec { - id: uuid::Uuid::new_v4().to_string(), - question: question_text.to_string(), - header, - multi_select, - options, - is_secret: false, + choices.push(QuestionOption { + label: label.to_string(), + description: String::new(), }); + option_ids.push((label.to_string(), id.clone())); } + Some(AntigravityAsk { + spec: QuestionSpec { + id: uuid::Uuid::new_v4().to_string(), + question: question.to_string(), + header: synthesize_header(question), + // ACP 权限响应只接受一个 optionId,原生代理逐题发送交互请求。 + multi_select: false, + options: choices, + is_secret: false, + }, + option_ids, + }) +} - Some(AntigravityAsk { specs }) +/// 只回传本题的一个原生选项;取消或无法表达的自由文本不伪装为首项。 +pub fn antigravity_option_id(outcome: &QuestionOutcome, ask: &AntigravityAsk) -> Option { + if outcome.declined || outcome.answers.len() != 1 { + return None; + } + let answer = &outcome.answers[0]; + if answer.question != ask.spec.question || answer.selected.len() != 1 { + return None; + } + ask.option_ids + .iter() + .find(|(label, _)| label == &answer.selected[0]) + .map(|(_, id)| id.clone()) } /// Serialize a resolved [`QuestionOutcome`] into grok's `AskUserQuestionExtResponse` @@ -3780,125 +3705,95 @@ mod tests { assert_eq!(input["questions"][0]["question"], output["answers"][0]["question"]); assert_eq!(output["answers"][0]["header"], ""); } - #[test] - fn is_antigravity_ask_tool_name_accepts_valid_names() { - for valid in [ - "ask_question", - "default_api:ask_question", - "default_api::ask_question", - "default_api/ask_question", - "default_api__ask_question", - "default_api.ask_question", - "default_api: ask_question", - "antigravity:ask_question", - "antigravity/ask_question", - "gemini:ask_question", - " DEFAULT_API:ASK_QUESTION ", - "ASK_QUESTION", - ] { - assert!( - is_antigravity_ask_tool_name(valid), - "{valid} must be recognized as antigravity ask tool" - ); - } + fn antigravity_interaction() -> Value { + json!({ + "toolCallId": "interaction_396e46cc", + "status": "pending", + "title": "关于接下来的开发工作,您希望优先聚焦于哪个核心方向?", + "rawInput": {}, + "kind": "other" + }) } - #[test] - fn is_antigravity_ask_tool_name_rejects_other_tools() { - for other in [ - "mcp__codeg-mcp__ask_user_question", - "ask_user_question", - "default_api:run_command", - "default_api:view_file", - "other_api:ask_question", - "ask_question_extra", - "", - ] { - assert!( - !is_antigravity_ask_tool_name(other), - "{other} must NOT be recognized as antigravity ask tool" - ); - } + fn antigravity_choices() -> Vec<(String, String)> { + vec![ + ("opaque-first".into(), "后端开发".into()), + ("opaque-second".into(), "前端开发".into()), + ] } #[test] - fn parse_antigravity_ask_maps_native_payload() { - let tool_call = json!({ - "title": "default_api:ask_question", - "rawInput": { - "questions": [ - { - "question": "Which architecture pattern do you prefer?", - "options": ["Option A (Clean)", "Option B (Layered)"], - "is_multi_select": false - } - ] - } - }); - let ask = parse_antigravity_ask(&tool_call).expect("should parse"); - assert_eq!(ask.specs.len(), 1); - assert_eq!(ask.specs[0].question, "Which architecture pattern do you prefer?"); - assert!(!ask.specs[0].multi_select); - assert_eq!(ask.specs[0].options.len(), 2); - assert_eq!(ask.specs[0].options[0].label, "Option A (Clean)"); - assert_eq!(ask.specs[0].options[1].label, "Option B (Layered)"); - assert!(!ask.specs[0].header.is_empty()); + fn antigravity_live_interaction_is_recognized() { + let ask = parse_antigravity_ask(&antigravity_interaction(), &antigravity_choices()) + .expect("实测交互格式应转换为问题卡片"); + assert_eq!(ask.spec.options[1].label, "前端开发"); + assert!(!ask.spec.multi_select); + assert_eq!(ask.option_ids[1].1, "opaque-second"); + + let mut no_kind = antigravity_interaction(); + no_kind.as_object_mut().unwrap().remove("kind"); + let ask_no_kind = parse_antigravity_ask(&no_kind, &antigravity_choices()) + .expect("实测 antigravity-acp 1.4.1 省略 kind 字段亦应正确识别为问题卡片"); + assert_eq!(ask_no_kind.spec.options[1].label, "前端开发"); } #[test] - fn parse_antigravity_ask_handles_serialized_questions_string() { - let questions_json_str = serde_json::to_string(&json!([ - { - "question": "Select target environments:", - "options": [ - {"label": "Staging", "description": "Pre-prod"}, - {"label": "Production", "description": "Live"} - ], - "is_multi_select": true - } - ])).unwrap(); - - let tool_call = json!({ - "title": "ask_question", - "rawInput": { - "arguments": { - "questions": questions_json_str - } - } - }); - let ask = parse_antigravity_ask(&tool_call).expect("should parse nested string"); - assert_eq!(ask.specs.len(), 1); - assert_eq!(ask.specs[0].question, "Select target environments:"); - assert!(ask.specs[0].multi_select); - assert_eq!(ask.specs[0].options[0].label, "Staging"); - assert_eq!(ask.specs[0].options[0].description, "Pre-prod"); - assert_eq!(ask.specs[0].options[1].label, "Production"); + fn antigravity_answer_preserves_second_option_and_decline() { + let ask = + parse_antigravity_ask(&antigravity_interaction(), &antigravity_choices()).unwrap(); + let mut outcome = QuestionOutcome { + answers: vec![QuestionAnsweredItem { + question: ask.spec.question.clone(), + header: ask.spec.header.clone(), + multi_select: false, + selected: vec!["前端开发".into()], + }], + declined: false, + }; + assert_eq!( + antigravity_option_id(&outcome, &ask).as_deref(), + Some("opaque-second") + ); + outcome.declined = true; + assert_eq!(antigravity_option_id(&outcome, &ask), None); + outcome.declined = false; + outcome.answers[0].selected = vec!["自由文本".into()]; + assert_eq!(antigravity_option_id(&outcome, &ask), None); + outcome.answers[0].selected = vec!["后端开发".into(), "前端开发".into()]; + assert_eq!(antigravity_option_id(&outcome, &ask), None); + outcome.answers.clear(); + assert_eq!(antigravity_option_id(&outcome, &ask), None); } #[test] - fn parse_antigravity_ask_declines_invalid_shapes() { - // Less than MIN_OPTIONS - assert!(parse_antigravity_ask(&json!({ - "title": "ask_question", - "rawInput": { - "questions": [{ "question": "Q?", "options": ["Only one"] }] - } - })).is_none()); - - // Empty question text - assert!(parse_antigravity_ask(&json!({ - "title": "ask_question", - "rawInput": { - "questions": [{ "question": " ", "options": ["A", "B"] }] - } - })).is_none()); - - // Duplicate options - assert!(parse_antigravity_ask(&json!({ - "title": "ask_question", - "rawInput": { - "questions": [{ "question": "Q?", "options": ["Same", "Same"] }] - } - })).is_none()); + fn antigravity_parser_preserves_ordinary_permissions_and_invalid_choices() { + for (field, value) in [ + ("toolCallId", json!("tool-1")), + ("toolCallId", json!("interaction_")), + ("title", json!(" ")), + ("kind", json!("execute")), + ("status", json!("completed")), + ("rawInput", json!({"command":"cargo build"})), + ] { + let mut call = antigravity_interaction(); + call[field] = value; + assert!( + parse_antigravity_ask(&call, &antigravity_choices()).is_none(), + "{call}" + ); + } + for choices in [ + vec![], + vec![("1".into(), "仅一项".into())], + vec![("1".into(), "重复".into()), ("2".into(), "重复".into())], + vec![("1".into(), "A".into()), ("1".into(), "B".into())], + vec![("".into(), "A".into()), ("2".into(), "B".into())], + vec![("1".into(), " ".into()), ("2".into(), "B".into())], + (0..5) + .map(|i| (i.to_string(), format!("选项{i}"))) + .collect(), + ] { + assert!(parse_antigravity_ask(&antigravity_interaction(), &choices).is_none()); + } } } diff --git a/src-tauri/src/parsers/antigravity.rs b/src-tauri/src/parsers/antigravity.rs index 19d1620775..84d6756dac 100644 --- a/src-tauri/src/parsers/antigravity.rs +++ b/src-tauri/src/parsers/antigravity.rs @@ -419,6 +419,30 @@ struct ErrorDetails { full_error: String, } +#[derive(Clone, PartialEq, ::prost::Message)] +struct AntigravityAskOption { + #[prost(string, tag = "1")] + id: String, + #[prost(string, tag = "2")] + text: String, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct AntigravityAskItem { + #[prost(string, tag = "1")] + question: String, + #[prost(message, repeated, tag = "2")] + options: Vec, + #[prost(string, repeated, tag = "4")] + chosen_ids: Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct AntigravityAskQuestions { + #[prost(message, repeated, tag = "1")] + items: Vec, +} + #[derive(Clone, PartialEq, ::prost::Message)] struct ErrorMessageStep { #[prost(message, optional, tag = "3")] @@ -465,6 +489,8 @@ struct Step { file_change: Option, #[prost(message, optional, tag = "116")] agency_tool_call: Option, + #[prost(message, optional, tag = "154")] + ask_question: Option, } /// `CortexStepStatus.CORTEX_STEP_STATUS_ERROR`. The other eleven values are not @@ -1316,6 +1342,40 @@ fn step_outcome(step: &Step) -> Option { }); } + if let Some(ask) = step.ask_question.as_ref() { + let answers: Vec = ask + .items + .iter() + .map(|item| { + let selected: Vec = item + .chosen_ids + .iter() + .map(|id| { + item.options + .iter() + .find(|opt| &opt.id == id) + .map(|opt| opt.text.clone()) + .unwrap_or_else(|| id.clone()) + }) + .collect(); + serde_json::json!({ + "header": "", + "question": item.question, + "multi_select": selected.len() > 1, + "selected": selected, + }) + }) + .collect(); + let payload = serde_json::json!({ + "answers": answers, + "declined": false, + }); + return Some(StepOutcome { + output: serde_json::to_string(&payload).ok().and_then(non_empty_capped), + is_error: status_error, + }); + } + if let Some(err) = step.error_message.as_ref() { let message = err .error @@ -2106,4 +2166,78 @@ mod tests { .join("conversations") ); } + + #[test] + fn antigravity_ask_question_step_outcome_generates_json_envelope() { + let dir = tempfile::tempdir().unwrap(); + let step = Step { + status: 3, + metadata: Some(StepMetadata { + created_at: ts(1_700_000_000), + tool_call: Some(ChatToolCall { + id: "call_1".into(), + name: "ask_question".into(), + arguments_json: "{}".into(), + }), + ..Default::default() + }), + ask_question: Some(AntigravityAskQuestions { + items: vec![ + AntigravityAskItem { + question: "您希望接下来优先开展哪一类开发任务?".into(), + options: vec![ + AntigravityAskOption { + id: "1".into(), + text: "(Recommended) 核心业务功能的新增与实现".into(), + }, + AntigravityAskOption { + id: "2".into(), + text: "现有功能的优化与缺陷修复".into(), + }, + ], + chosen_ids: vec!["1".into()], + }, + AntigravityAskItem { + question: "在开发交付与流程配合上,您倾向于哪种模式?".into(), + options: vec![ + AntigravityAskOption { + id: "1".into(), + text: "(Recommended) 快速迭代交付,先实现最小可行版本(MVP)供确认".into(), + }, + AntigravityAskOption { + id: "2".into(), + text: "阶段性里程碑交付,按完整模块验收".into(), + }, + ], + chosen_ids: vec!["1".into()], + }, + ], + }), + ..Default::default() + }; + + write_steps(dir.path(), "test-session", &[step]); + let steps = read_steps(&dir.path().join("test-session.db")); + let parsed = project_steps(&steps); + let blocks = &parsed.turns[0].blocks; + let mut found_result = false; + for block in blocks { + if let ContentBlock::ToolResult { output_preview: Some(out), .. } = block { + found_result = true; + let val: serde_json::Value = serde_json::from_str(out).unwrap(); + assert_eq!(val["declined"], false); + let answers = val["answers"].as_array().unwrap(); + assert_eq!(answers.len(), 2); + assert_eq!( + answers[0]["selected"].as_array().unwrap()[0].as_str().unwrap(), + "(Recommended) 核心业务功能的新增与实现" + ); + assert_eq!( + answers[1]["selected"].as_array().unwrap()[0].as_str().unwrap(), + "(Recommended) 快速迭代交付,先实现最小可行版本(MVP)供确认" + ); + } + } + assert!(found_result, "must find ToolResult containing user chosen answers"); + } } diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index fa370d051f..753ea3b3ff 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -20,6 +20,7 @@ const CANCEL_DELEGATION_SUFFIX_RE = /[^a-z0-9]cancel_delegation$/ const CREATE_GOAL_SUFFIX_RE = /[^a-z0-9]create_goal$/ const UPDATE_GOAL_SUFFIX_RE = /[^a-z0-9]update_goal$/ const ASK_USER_QUESTION_SUFFIX_RE = /[^a-z0-9]ask_user_question$/ +const ASK_QUESTION_SUFFIX_RE = /[^a-z0-9]ask_question$/ const CHECK_USER_FEEDBACK_SUFFIX_RE = /[^a-z0-9]check_user_feedback$/ /** @@ -64,6 +65,7 @@ export function isAgentLikeToolName(toolName: string): boolean { // forms, since this runs pre-normalize. name === "question" || name === "ask_user_question" || + name === "ask_question" || // Kimi Code's native AskUserQuestion (also Claude's SDK tool of the same // name). The history parsers keep this raw camel-case name; the live path // already collapses it to "question". @@ -84,6 +86,7 @@ export function isAgentLikeToolName(toolName: string): boolean { if (CREATE_GOAL_SUFFIX_RE.test(name)) return true if (UPDATE_GOAL_SUFFIX_RE.test(name)) return true if (ASK_USER_QUESTION_SUFFIX_RE.test(name)) return true + if (ASK_QUESTION_SUFFIX_RE.test(name)) return true if (CHECK_USER_FEEDBACK_SUFFIX_RE.test(name)) return true if (CODEG_MCP_WORKBENCH_NAMES.has(name)) return true if (CODEG_MCP_WORKBENCH_SUFFIX_RE.test(name)) return true diff --git a/src/lib/ask-question.ts b/src/lib/ask-question.ts index 3b09bdd459..cedac88c7c 100644 --- a/src/lib/ask-question.ts +++ b/src/lib/ask-question.ts @@ -59,13 +59,15 @@ export function splitRecommended(label: string): { text: string recommended: boolean } { - const m = label.match(/^(.*?)\s*\(recommended\)\s*$/i) - const text = m?.[1].trim() - // Only treat "(Recommended)" as a suffix when real text precedes it — a bare - // "(Recommended)" label keeps its literal text rather than rendering empty. - return text - ? { text, recommended: true } - : { text: label, recommended: false } + const suffixMatch = label.match(/^(.*?)\s*\(recommended\)\s*$/i) + if (suffixMatch && suffixMatch[1].trim()) { + return { text: suffixMatch[1].trim(), recommended: true } + } + const prefixMatch = label.match(/^\s*\(recommended\)\s*(.*?)$/i) + if (prefixMatch && prefixMatch[1].trim()) { + return { text: prefixMatch[1].trim(), recommended: true } + } + return { text: label, recommended: false } } function asString(value: unknown): string { @@ -82,6 +84,11 @@ function parseOptions(raw: unknown): AskQuestionOption[] { if (!Array.isArray(raw)) return [] const out: AskQuestionOption[] = [] for (const item of raw) { + if (typeof item === "string") { + const label = item.trim() + if (label) out.push({ label, description: "" }) + continue + } if (!item || typeof item !== "object") continue const obj = item as Record const label = asString(obj.label) @@ -131,7 +138,11 @@ export function parseAskQuestionInput( out.push({ question, header: asString(obj.header), - multiSelect: obj.multiSelect === true || obj.multi_select === true, + multiSelect: + obj.multiSelect === true || + obj.multi_select === true || + obj.is_multi_select === true || + obj.isMultiSelect === true, options, isSecret: obj.isSecret === true || obj.is_secret === true, id: typeof obj.id === "string" && obj.id ? obj.id : undefined, diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index c9d71b25f3..30b009e50e 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -160,6 +160,8 @@ const EXACT_TOOL_NAME_ALIASES: Record = { question: "question", ask_user_question: "question", askuserquestion: "question", + ask_question: "question", + askquestion: "question", // codeg-mcp ask-user-question companion tool (server prefix varies by host; // the suffix rule in `normalizeToolName` covers the other separators) "mcp__codeg-mcp__ask_user_question": "question", @@ -623,6 +625,7 @@ function normalizeUncached(toolName: string): string { // catches the unprefixed form, so collapse every separator here. Note the // freeform matcher below intentionally does NOT catch the underscore form. if (/[^a-z0-9]ask_user_question$/.test(canonical)) return "question" + if (/[^a-z0-9]ask_question$/.test(canonical)) return "question" // codeg-mcp live-feedback poll. Same host-prefix story as the delegation tools // (`mcp____check_user_feedback`, `/check_user_feedback`, …) — From c8ba1988c12f4aaaa2be1c6ada9fc8ad090ee8db Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 14:36:44 +0800 Subject: [PATCH 5/8] fix(acp): keep Antigravity's closing frame off the answered question card antigravity-acp 1.4.1 (`server.py::_ask_user_handler`, confirmed on a live run) wraps every question in frames of its own under the same `interaction_` id the bridge records its answered card under: a pending `tool_call` titled with the question before the permission request, and a `tool_call_update` carrying `rawOutput: "Response received"` (or `"failed"` + `"Interaction cancelled"`) after the answer. Both reducers replace a call's output with any update that carries one, so the answered card ended up reading "no selection", and a declined one flipped to failed. The bridge now marks the id in `SessionState` as it records the card, before answering, and the live loop drops Antigravity's closing frame for a marked id. The opening frame stays: it is the row the card lands on. A question the bridge leaves on the approval card, or that a drain abandons, is never marked, so its placeholder row still settles. Also align the bridge with every other native ask bridge: honor the ask feature's kill switch (with it off, the approval card stays the way to answer), check the id prefix before serializing the request, and replace the diagnostic block that re-implemented the parser's validation with one line. --- src-tauri/src/acp/connection.rs | 374 ++++++++++++++++++++++------- src-tauri/src/acp/question.rs | 20 +- src-tauri/src/acp/session_state.rs | 11 + 3 files changed, 321 insertions(+), 84 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 1b3f2d622e..8a05c09296 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -9721,6 +9721,23 @@ async fn try_bridge_pi_select_ask( } /// 将 Antigravity 原生 interaction_* 问答接入共享卡片,回传选中的原始 optionId。 +/// +/// antigravity-acp asks each question of the model's `ask_question` (and its +/// workspace-trust prompt) as one `session/request_permission` whose tool call +/// is `{toolCallId: "interaction_", title: , rawInput: {}}` and +/// whose options are the choices (see +/// [`crate::acp::question::parse_antigravity_ask`]). The bridge mirrors +/// [`try_bridge_pi_select_ask`]: the card replaces the approval card, the pick +/// goes back as the chosen `optionId`, and the answered card is recorded under +/// the interaction's id, on the placeholder row Antigravity opened for it (see +/// [`record_antigravity_answer_card`]); Antigravity's closing "Response +/// received" update for that id is then dropped so it cannot overwrite the +/// answer. +/// +/// `Err(responder)` hands the request back for the ordinary permission path: +/// every other request, an interaction the card cannot show faithfully, the +/// ask feature switched off, or an ask already pending on the connection. +// Same `Err` hand-back as `try_auto_allow_codeg_ask`; see its allow note. #[allow(clippy::result_large_err)] async fn try_bridge_antigravity_ask( access: &Option<( @@ -9734,72 +9751,36 @@ async fn try_bridge_antigravity_ask( req: &RequestPermissionRequest, responder: Responder, ) -> Result<(), Responder> { - if state.read().await.agent_type != AgentType::Antigravity { + let tool_call_id = req.tool_call.tool_call_id.to_string(); + if !crate::acp::question::is_antigravity_interaction_id(&tool_call_id) + || state.read().await.agent_type != AgentType::Antigravity + { + return Err(responder); + } + let Some((questions, ask_cfg)) = access else { + return Err(responder); + }; + // Same kill switch as the codeg-mcp ask tool and every other native ask + // bridge (grok, cursor, codex elicitation, pi): when the feature is off, + // the approval card stays the way to answer. + if !ask_cfg.is_enabled().await { return Err(responder); } - let tool_call_id = req.tool_call.tool_call_id.to_string(); let tool_call = serde_json::to_value(&req.tool_call).unwrap_or_default(); let options: Vec<_> = req .options .iter() .map(|opt| (opt.option_id.to_string(), opt.name.clone())) .collect(); - let Some((questions, _ask_cfg)) = access else { - if tool_call_id.starts_with("interaction_") { - tracing::warn!( - "[ACP] Antigravity interaction bridge has no question access conn={} tool_call_id={}", - connection_id, - tool_call_id - ); - } - return Err(responder); - }; - let Some(ask) = crate::acp::question::parse_antigravity_ask(&tool_call, &options) else { - let id_matches = tool_call - .get("toolCallId") - .and_then(serde_json::Value::as_str) - .is_some_and(|id| id.starts_with("interaction_") && id.len() > 12); - if id_matches { - let title_present = tool_call - .get("title") - .and_then(serde_json::Value::as_str) - .is_some_and(|title| !title.trim().is_empty()); - let kind = tool_call - .get("kind") - .and_then(serde_json::Value::as_str) - .unwrap_or("missing"); - let status = tool_call - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or("missing"); - let raw_input_empty_object = tool_call - .get("rawInput") - .and_then(serde_json::Value::as_object) - .is_some_and(serde_json::Map::is_empty); - let mut seen_ids = std::collections::HashSet::new(); - let mut seen_names = std::collections::HashSet::new(); - let options_valid = options.iter().all(|(id, name)| { - !id.trim().is_empty() - && !name.trim().is_empty() - && name.chars().count() <= crate::acp::question::MAX_QUESTION_TEXT_CHARS - && seen_ids.insert(id) - && seen_names.insert(name.trim()) - }); - tracing::warn!( - "[ACP] Antigravity interaction bridge rejected request conn={} tool_call_id={} id_matches={} title_present={} kind={} status={} raw_input_empty_object={} option_count={} options_valid={}", - connection_id, - tool_call_id, - id_matches, - title_present, - kind, - status, - raw_input_empty_object, - options.len(), - options_valid - ); - } + tracing::warn!( + "[ACP] Antigravity interaction kept on the approval card; the question card \ + cannot show it faithfully conn={} tool_call_id={} option_count={}", + connection_id, + tool_call_id, + options.len() + ); return Err(responder); }; @@ -9848,30 +9829,10 @@ async fn try_bridge_antigravity_ask( declined: true, } }; - emit_with_state( - &state, - &emitter, - AcpEvent::ToolCall { - tool_call_id, - title: "ask_user_question".to_string(), - kind: "other".to_string(), - status: "completed".to_string(), - content: None, - raw_input: Some( - crate::acp::question::grok_result_card_input(std::slice::from_ref( - &ask.spec, - )) - .to_string(), - ), - raw_output: Some( - crate::acp::question::grok_result_card_output(&recorded).to_string(), - ), - locations: None, - meta: None, - images: None, - }, - ) - .await; + // Before answering: Antigravity's closing frame for this id + // follows the answer and must find the card recorded. + record_antigravity_answer_card(&state, &emitter, tool_call_id, &ask.spec, &recorded) + .await; let response_outcome = match option_id { Some(id) => { @@ -18179,6 +18140,78 @@ fn grok_meta_marks_ask_user( == Some("ask_user") } +/// Whether this `tool_call_update` is Antigravity closing a question whose +/// answered card the bridge recorded, and so must be dropped. Consumes the +/// record. +/// +/// Measured against antigravity-acp 1.4.1: each question opens a `tool_call` +/// (`interaction_`, titled with the question, `rawInput: {}`, `pending`), +/// is asked through `session/request_permission` under the same id, and is +/// closed by a `tool_call_update` carrying only `status` and a fixed `rawOutput` +/// ("Response received" / "Interaction cancelled"). The server emits no frame +/// for the model's `ask_question` call itself. +/// +/// The opening frame is kept: it is the row the answered card lands on, and the +/// row an approval card's answer settles. The closing frame is dropped only +/// after [`record_antigravity_answer_card`], like grok's redundant ask frames +/// (see [`grok_meta_marks_ask_user`]): both reducers replace a call's output +/// with any update that carries one, so it would turn the recorded answer into +/// "no selection" (and a declined card into a failed one). Without a recorded +/// card (the bridge left the question on the approval card, or a drain +/// abandoned it) the frame settles the placeholder as usual. +async fn take_antigravity_answered_close( + agent_type: AgentType, + state: &Arc>, + tool_call_id: &str, +) -> bool { + agent_type == AgentType::Antigravity + && crate::acp::question::is_antigravity_interaction_id(tool_call_id) + && state + .write() + .await + .antigravity_answered_interactions + .remove(tool_call_id) +} + +/// Record the answered card for one Antigravity question under the +/// interaction's own id (the placeholder row Antigravity opened for it), and +/// mark the id so the closing frame Antigravity sends next cannot overwrite it +/// (see [`take_antigravity_answered_close`]). Must run before the permission +/// request is answered: Antigravity sends that frame only afterwards. +async fn record_antigravity_answer_card( + state: &Arc>, + emitter: &EventEmitter, + tool_call_id: String, + spec: &crate::acp::question::QuestionSpec, + recorded: &crate::acp::question::QuestionOutcome, +) { + state + .write() + .await + .antigravity_answered_interactions + .insert(tool_call_id.clone()); + emit_with_state( + state, + emitter, + AcpEvent::ToolCall { + tool_call_id, + title: "ask_user_question".to_string(), + kind: "other".to_string(), + status: "completed".to_string(), + content: None, + raw_input: Some( + crate::acp::question::grok_result_card_input(std::slice::from_ref(spec)) + .to_string(), + ), + raw_output: Some(crate::acp::question::grok_result_card_output(recorded).to_string()), + locations: None, + meta: None, + images: None, + }, + ) + .await; +} + /// The questions of the Qoder `AskUserQuestion` call this opening `tool_call` /// announces, or `None` when it announces anything else. Qoder states the /// tool's name only here (`_meta.qoder.toolName`); the questions may be empty @@ -21667,6 +21700,12 @@ async fn emit_conversation_update( } SessionUpdate::ToolCallUpdate(mut tcu) => { let tool_call_id = tcu.tool_call_id.to_string(); + // The closing "Response received" frame of an Antigravity question + // would overwrite the answered card the bridge recorded (see + // `take_antigravity_answered_close`). + if take_antigravity_answered_close(agent_type, state, &tool_call_id).await { + return; + } // A fork's copy of its own launch result: dropped before the `_meta` // ledger reads it, and the real result is taken off the raw SDK // stream instead (see `claude_fork_inherited_result`). @@ -41839,7 +41878,9 @@ mod tests { for (agent_type, enabled, decline, bridged) in [ (AgentType::Antigravity, true, false, true), (AgentType::Antigravity, true, true, true), - (AgentType::Antigravity, false, false, true), + // The ask feature's kill switch keeps the approval card, as it does + // for every other native ask bridge. + (AgentType::Antigravity, false, false, false), (AgentType::Codex, true, false, false), ] { let (client_end, agent_end) = Channel::duplex(); @@ -41952,6 +41993,177 @@ mod tests { } } + /// The frames antigravity-acp 1.4.1 sends around one question, recorded on a + /// live run, with what the bridge does in between. When the bridge recorded + /// an answered (or declined) card, the closing "Response received" update is + /// dropped and the card survives (it used to replace the answer and leave the + /// card reading "no selection"). Without a recorded card (the question went + /// to the approval card, or was abandoned) the closing frame settles the + /// placeholder row as before. Another agent's look-alike id is left alone. + #[tokio::test] + async fn antigravity_interaction_frames_do_not_overwrite_the_bridged_card() { + use crate::acp::session_state::{ToolCallOutput, ToolCallStatus}; + let id = "interaction_6fc001b5"; + let opening = || -> SessionUpdate { + serde_json::from_value(serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": id, + "title": "Which area first?", + "status": "pending", + "rawInput": {}, + })) + .expect("1.4.1 opening frame") + }; + let closing = |status: &str, raw_output: &str| -> SessionUpdate { + serde_json::from_value(serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": id, + "status": status, + "rawOutput": raw_output, + })) + .expect("1.4.1 closing frame") + }; + let spec = crate::acp::question::QuestionSpec { + id: "q1".into(), + question: "Which area first?".into(), + header: "Which area".into(), + multi_select: false, + options: ["(Recommended) Backend API", "Frontend UI"] + .into_iter() + .map(|label| crate::acp::question::QuestionOption { + label: label.into(), + description: String::new(), + }) + .collect(), + is_secret: false, + }; + let answered = crate::acp::question::QuestionOutcome { + answers: vec![crate::acp::question::QuestionAnsweredItem { + question: spec.question.clone(), + header: spec.header.clone(), + multi_select: false, + selected: vec!["Frontend UI".into()], + }], + declined: false, + }; + let declined = crate::acp::question::QuestionOutcome { + answers: Vec::new(), + declined: true, + }; + // (agent, the card the bridge recorded, closing frame, frames for the id + // that reach the reducers) + for (agent_type, card, (closing_status, closing_output), frames_expected) in [ + ( + AgentType::Antigravity, + Some(&answered), + ("completed", "Response received"), + 2, + ), + ( + AgentType::Antigravity, + Some(&declined), + ("failed", "Interaction cancelled"), + 2, + ), + ( + AgentType::Antigravity, + None, + ("failed", "Interaction cancelled"), + 2, + ), + ( + AgentType::Codex, + Some(&answered), + ("completed", "Response received"), + 3, + ), + ] { + let state = Arc::new(RwLock::new(SessionState::new( + "conn-agy-frames".into(), + agent_type, + None, + "win".into(), + None, + ))); + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + emit_conversation_update( + &state, + &EventEmitter::Noop, + agent_type, + opening(), + None, + &mut cache, + &mut cb, + ) + .await; + if let Some(outcome) = card { + record_antigravity_answer_card( + &state, + &EventEmitter::Noop, + id.into(), + &spec, + outcome, + ) + .await; + } + emit_conversation_update( + &state, + &EventEmitter::Noop, + agent_type, + closing(closing_status, closing_output), + None, + &mut cache, + &mut cb, + ) + .await; + + let guard = state.read().await; + let frames = guard + .recent_events_after(0) + .unwrap_or_default() + .into_iter() + .filter(|event| { + matches!( + &event.payload, + AcpEvent::ToolCall { tool_call_id, .. } + | AcpEvent::ToolCallUpdate { tool_call_id, .. } + if tool_call_id == id + ) + }) + .count(); + let case = format!("{agent_type:?} card={}", card.is_some()); + assert_eq!(frames, frames_expected, "{case}"); + let call = guard + .active_tool_calls + .get(id) + .expect("the row is tracked"); + let output = match call.output.as_ref() { + Some(ToolCallOutput::Json { value }) => value.to_string(), + Some(ToolCallOutput::Text { content }) => content.clone(), + other => panic!("unexpected output {other:?} ({case})"), + }; + match (agent_type, card) { + (AgentType::Antigravity, Some(outcome)) => { + let expected = crate::acp::question::grok_result_card_output(outcome); + assert_eq!(output, expected.to_string(), "{case}"); + assert_eq!(call.label, "ask_user_question", "{case}"); + assert!(matches!(call.status, ToolCallStatus::Completed), "{case}"); + } + (AgentType::Antigravity, None) => { + assert_eq!(call.label, "Which area first?", "{case}"); + assert!(matches!(call.status, ToolCallStatus::Failed), "{case}"); + assert!(output.contains("Interaction cancelled"), "{output}"); + } + _ => assert!(output.contains("Response received"), "{output}"), + } + // Each record is consumed by the frame it was waiting for. + if agent_type == AgentType::Antigravity { + assert!(guard.antigravity_answered_interactions.is_empty(), "{case}"); + } + } + } + /// Answers every question with its first option the moment it is asked. struct AnswersFirstOption; diff --git a/src-tauri/src/acp/question.rs b/src-tauri/src/acp/question.rs index f67dc48ab2..4fdd1b42f8 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -701,6 +701,22 @@ pub fn is_codeg_ask_tool_name(name: &str) -> bool { crate::acp::delegation::is_codeg_mcp_tool(name, "ask_user_question") } +/// The id prefix antigravity-acp gives the synthetic tool call of one of its +/// own questions: `server.py::_ask_user_handler` mints +/// `interaction_<8 hex>` for each question it asks (the model's `ask_question`, +/// asked one question at a time, and the workspace-trust prompt alike). It is +/// the only producer of that prefix in 1.4.1. +pub const ANTIGRAVITY_INTERACTION_ID_PREFIX: &str = "interaction_"; + +/// True when `id` has the shape of an antigravity-acp question's tool call id +/// (see [`ANTIGRAVITY_INTERACTION_ID_PREFIX`]). The prefix alone says nothing +/// about the agent, so callers must also check that the connection is +/// Antigravity's. +pub fn is_antigravity_interaction_id(id: &str) -> bool { + id.strip_prefix(ANTIGRAVITY_INTERACTION_ID_PREFIX) + .is_some_and(|rest| !rest.is_empty()) +} + /// Antigravity 原生交互通过权限选项承载答案,保留文字到 optionId 的映射。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AntigravityAsk { @@ -716,9 +732,7 @@ pub fn parse_antigravity_ask( options: &[(String, String)], ) -> Option { let id = tool_call.get("toolCallId")?.as_str()?; - if id.strip_prefix("interaction_")?.is_empty() - || !(MIN_OPTIONS..=MAX_OPTIONS).contains(&options.len()) - { + if !is_antigravity_interaction_id(id) || !(MIN_OPTIONS..=MAX_OPTIONS).contains(&options.len()) { return None; } if let Some(kind) = tool_call.get("kind").and_then(Value::as_str) { diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index ddc233a8c4..d92e0b8aa7 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -564,6 +564,16 @@ pub struct SessionState { /// to hand the result in. Backend-only. pub result_tool_available: bool, + /// Antigravity question ids (`interaction_`) whose answered card + /// `try_bridge_antigravity_ask` has recorded. Antigravity closes each such + /// id with a `tool_call_update` of its own ("Response received" / + /// "Interaction cancelled") that would replace the card's answer, so the + /// live loop drops that frame for an id in this set, removing the id as it + /// does. A question left on the approval card, or abandoned before an + /// answer, is never recorded, so its placeholder row still settles. + /// Backend-only. + pub antigravity_answered_interactions: BTreeSet, + /// Whether live-feedback notes for THIS session go over the native ACP /// `_session/steering` push channel instead of the `check_user_feedback` /// pull tool. Synthesized ONCE at initialize from three gates (extension @@ -891,6 +901,7 @@ impl SessionState { delegation_enabled: false, feedback_tool_available: false, result_tool_available: false, + antigravity_answered_interactions: BTreeSet::new(), native_steering_available: false, codex_user_input_shape: None, neutral_goal_channel: false, From 413eb6ae9ae21d8104ed7044e9ede10d6b465f8d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 14:36:44 +0800 Subject: [PATCH 6/8] fix(parsers): read Antigravity's skipped, multi-select and written-in answers `Step` field 154 is `exa.cortex_pb.CortexStepAskQuestion` (checked against the 1.4.1 descriptors), and each entry carries more than the question, options and picks the parser decoded: `is_multi_select` (3), `write_in_response` (5) and `skipped` (6). On a live run, dismissing the card left every question skipped with nothing chosen, which reopened as "no selection" rather than the decline the live bridge records; a multi-select question answered with its one allowed pick reopened as single-select. Decode the three fields, report an all-skipped ask as declined, take multi-select from the step, keep a write-in as one more selection, and trim option text the way the card trims the option strings it reads. --- src-tauri/src/parsers/antigravity.rs | 241 ++++++++++++++++++++------- 1 file changed, 179 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/parsers/antigravity.rs b/src-tauri/src/parsers/antigravity.rs index 84d6756dac..68240f3f2f 100644 --- a/src-tauri/src/parsers/antigravity.rs +++ b/src-tauri/src/parsers/antigravity.rs @@ -419,6 +419,7 @@ struct ErrorDetails { full_error: String, } +/// `exa.cortex_pb.AskQuestionOption`. #[derive(Clone, PartialEq, ::prost::Message)] struct AntigravityAskOption { #[prost(string, tag = "1")] @@ -427,16 +428,30 @@ struct AntigravityAskOption { text: String, } +/// `exa.cortex_pb.AskQuestionEntry`: one question of an `ask_question` step and +/// the user's answer to it. Checked against the 1.4.1 descriptors and a live +/// run: options are numbered `"1"`, `"2"`, …; a question the user dismissed (or +/// never reached, once an earlier one was dismissed) is `skipped` with nothing +/// chosen; a multi-select question is still answered with one option, because +/// the ACP server asks it as a single-choice permission request. #[derive(Clone, PartialEq, ::prost::Message)] struct AntigravityAskItem { #[prost(string, tag = "1")] question: String, #[prost(message, repeated, tag = "2")] options: Vec, + #[prost(bool, tag = "3")] + is_multi_select: bool, + /// `selected_option_ids`. #[prost(string, repeated, tag = "4")] chosen_ids: Vec, + #[prost(string, tag = "5")] + write_in_response: String, + #[prost(bool, tag = "6")] + skipped: bool, } +/// `exa.cortex_pb.CortexStepAskQuestion`, carried on `Step` field 154. #[derive(Clone, PartialEq, ::prost::Message)] struct AntigravityAskQuestions { #[prost(message, repeated, tag = "1")] @@ -1343,33 +1358,51 @@ fn step_outcome(step: &Step) -> Option { } if let Some(ask) = step.ask_question.as_ref() { + // The `{answers, declined}` envelope the question card reads, keyed by + // question text (the model's `ask_question` arguments carry no header). + // Selections are the options' text, trimmed the way the card trims the + // option strings it reads from those arguments; a written-in answer + // rides along as one more selection, which the card shows as "Other". let answers: Vec = ask .items .iter() .map(|item| { - let selected: Vec = item + let mut selected: Vec = item .chosen_ids .iter() .map(|id| { item.options .iter() .find(|opt| &opt.id == id) - .map(|opt| opt.text.clone()) + .map(|opt| opt.text.trim().to_string()) .unwrap_or_else(|| id.clone()) }) .collect(); + let write_in = item.write_in_response.trim(); + if !write_in.is_empty() { + selected.push(write_in.to_string()); + } serde_json::json!({ "header": "", "question": item.question, - "multi_select": selected.len() > 1, + "multi_select": item.is_multi_select, "selected": selected, }) }) .collect(); - let payload = serde_json::json!({ - "answers": answers, - "declined": false, - }); + // Dismissing the card skips every question with nothing chosen; record + // that as the live bridge does (a decline), not as "no selection". + let declined = !ask.items.is_empty() + && ask.items.iter().all(|item| { + item.skipped + && item.chosen_ids.is_empty() + && item.write_in_response.trim().is_empty() + }); + let payload = if declined { + serde_json::json!({ "answers": [], "declined": true }) + } else { + serde_json::json!({ "answers": answers, "declined": false }) + }; return Some(StepOutcome { output: serde_json::to_string(&payload).ok().and_then(non_empty_capped), is_error: status_error, @@ -2167,8 +2200,27 @@ mod tests { ); } - #[test] - fn antigravity_ask_question_step_outcome_generates_json_envelope() { + /// One question of an `ask_question` step, options numbered from `"1"` the + /// way antigravity-acp 1.4.1 records them. + fn ask_item(question: &str, options: &[&str], chosen: &[&str]) -> AntigravityAskItem { + AntigravityAskItem { + question: question.into(), + options: options + .iter() + .enumerate() + .map(|(i, text)| AntigravityAskOption { + id: (i + 1).to_string(), + text: (*text).into(), + }) + .collect(), + chosen_ids: chosen.iter().map(|id| (*id).into()).collect(), + ..Default::default() + } + } + + /// Round-trip one `ask_question` step (`Step` field 154) through the store + /// and return the tool call's name and its result envelope. + fn ask_step_outcome(items: Vec) -> (String, serde_json::Value) { let dir = tempfile::tempdir().unwrap(); let step = Step { status: 3, @@ -2181,63 +2233,128 @@ mod tests { }), ..Default::default() }), - ask_question: Some(AntigravityAskQuestions { - items: vec![ - AntigravityAskItem { - question: "您希望接下来优先开展哪一类开发任务?".into(), - options: vec![ - AntigravityAskOption { - id: "1".into(), - text: "(Recommended) 核心业务功能的新增与实现".into(), - }, - AntigravityAskOption { - id: "2".into(), - text: "现有功能的优化与缺陷修复".into(), - }, - ], - chosen_ids: vec!["1".into()], + ask_question: Some(AntigravityAskQuestions { items }), + ..Default::default() + }; + write_steps(dir.path(), "test-session", &[step]); + let parsed = project_steps(&read_steps(&dir.path().join("test-session.db"))); + let blocks: Vec<&ContentBlock> = parsed.turns.iter().flat_map(|t| &t.blocks).collect(); + let name = blocks + .iter() + .find_map(|block| match block { + ContentBlock::ToolUse { tool_name, .. } => Some(tool_name.clone()), + _ => None, + }) + .expect("the ask_question call"); + let outputs: Vec<&String> = blocks + .iter() + .filter_map(|block| match block { + ContentBlock::ToolResult { + output_preview: Some(out), + .. + } => Some(out), + _ => None, + }) + .collect(); + assert_eq!(outputs.len(), 1, "one result for the ask"); + ( + name, + serde_json::from_str(outputs[0]).expect("JSON envelope"), + ) + } + + #[test] + fn antigravity_ask_question_step_outcome_generates_json_envelope() { + let (name, val) = ask_step_outcome(vec![ + ask_item( + "您希望接下来优先开展哪一类开发任务?", + &[ + "(Recommended) 核心业务功能的新增与实现", + "现有功能的优化与缺陷修复", + ], + &["1"], + ), + ask_item( + "在开发交付与流程配合上,您倾向于哪种模式?", + &[ + "(Recommended) 快速迭代交付,先实现最小可行版本(MVP)供确认", + "阶段性里程碑交付,按完整模块验收", + ], + &["2"], + ), + ]); + // The card is keyed to this name (`ask_question` → "question" in the + // frontend's tool-name aliases). + assert_eq!(name, "ask_question"); + assert_eq!( + val, + serde_json::json!({ + "answers": [ + { + "header": "", + "question": "您希望接下来优先开展哪一类开发任务?", + "multi_select": false, + "selected": ["(Recommended) 核心业务功能的新增与实现"], }, - AntigravityAskItem { - question: "在开发交付与流程配合上,您倾向于哪种模式?".into(), - options: vec![ - AntigravityAskOption { - id: "1".into(), - text: "(Recommended) 快速迭代交付,先实现最小可行版本(MVP)供确认".into(), - }, - AntigravityAskOption { - id: "2".into(), - text: "阶段性里程碑交付,按完整模块验收".into(), - }, - ], - chosen_ids: vec!["1".into()], + { + "header": "", + "question": "在开发交付与流程配合上,您倾向于哪种模式?", + "multi_select": false, + "selected": ["阶段性里程碑交付,按完整模块验收"], }, ], - }), - ..Default::default() + "declined": false, + }) + ); + } + + /// A multi-select question keeps its kind even though the ACP server could + /// only take one pick for it; a written-in answer and padded option text + /// come out the way the card reads them. + #[test] + fn antigravity_ask_question_outcome_keeps_multi_select_and_write_in() { + let mut multi = ask_item("Pick tags", &["Alpha", "Beta", " Gamma "], &["3"]); + multi.is_multi_select = true; + let mut write_in = ask_item("Anything else?", &["No", "Later"], &[]); + write_in.write_in_response = " Ship it on Friday ".into(); + let (_, val) = ask_step_outcome(vec![multi, write_in]); + assert_eq!(val["declined"], false); + assert_eq!(val["answers"][0]["multi_select"], true); + assert_eq!(val["answers"][0]["selected"], serde_json::json!(["Gamma"])); + assert_eq!(val["answers"][1]["multi_select"], false); + assert_eq!( + val["answers"][1]["selected"], + serde_json::json!(["Ship it on Friday"]) + ); + } + + /// Dismissing the card leaves every question skipped with nothing chosen: + /// that reads as a decline, as the live bridge records it. Dismissing a + /// later question keeps the earlier answer. + #[test] + fn antigravity_ask_question_outcome_reads_a_dismissed_card_as_declined() { + let skipped = |mut item: AntigravityAskItem| { + item.skipped = true; + item }; + let (_, all_skipped) = ask_step_outcome(vec![ + skipped(ask_item("Which area first?", &["Backend", "Frontend"], &[])), + skipped(ask_item("Pick tags", &["Alpha", "Beta"], &[])), + ]); + assert_eq!( + all_skipped, + serde_json::json!({ "answers": [], "declined": true }) + ); - write_steps(dir.path(), "test-session", &[step]); - let steps = read_steps(&dir.path().join("test-session.db")); - let parsed = project_steps(&steps); - let blocks = &parsed.turns[0].blocks; - let mut found_result = false; - for block in blocks { - if let ContentBlock::ToolResult { output_preview: Some(out), .. } = block { - found_result = true; - let val: serde_json::Value = serde_json::from_str(out).unwrap(); - assert_eq!(val["declined"], false); - let answers = val["answers"].as_array().unwrap(); - assert_eq!(answers.len(), 2); - assert_eq!( - answers[0]["selected"].as_array().unwrap()[0].as_str().unwrap(), - "(Recommended) 核心业务功能的新增与实现" - ); - assert_eq!( - answers[1]["selected"].as_array().unwrap()[0].as_str().unwrap(), - "(Recommended) 快速迭代交付,先实现最小可行版本(MVP)供确认" - ); - } - } - assert!(found_result, "must find ToolResult containing user chosen answers"); + let (_, partly) = ask_step_outcome(vec![ + ask_item("Which area first?", &["Backend", "Frontend"], &["2"]), + skipped(ask_item("Pick tags", &["Alpha", "Beta"], &[])), + ]); + assert_eq!(partly["declined"], false); + assert_eq!( + partly["answers"][0]["selected"], + serde_json::json!(["Frontend"]) + ); + assert_eq!(partly["answers"][1]["selected"], serde_json::json!([])); } } From 735c470486bb3b6e41e05f09208e9dd73eb695be Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 14:36:44 +0800 Subject: [PATCH 7/8] fix(chat): map only the bare ask_question name to the question card The `*ask_question` suffix rule (and the `askquestion` alias) also caught host-prefixed third-party MCP tools of that name, such as DeepWiki's `ask_question` (`mcp__deepwiki__ask_question`, `deepwiki/ask_question`, ...). Its `{repoName, question}` input yields no questions, so in reopened sessions the question card rendered nothing where the MCP card used to be. Antigravity needs only the bare name its history parser keeps (the live bridge records its card as `ask_user_question`), so keep that one. Add tests for what the change relies on: the leading "(Recommended)" prefix, and the real ask_question arguments (string options, snake_case `is_multi_select`) matched against the parser's answer envelope. --- src/lib/adapters/tool-kind-classifier.test.ts | 7 ++ src/lib/adapters/tool-kind-classifier.ts | 5 +- src/lib/ask-question.test.ts | 105 ++++++++++++++++++ src/lib/tool-call-normalization.test.ts | 17 +++ src/lib/tool-call-normalization.ts | 6 +- 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/tool-kind-classifier.test.ts b/src/lib/adapters/tool-kind-classifier.test.ts index 61e6117ce8..dd8ebab7f4 100644 --- a/src/lib/adapters/tool-kind-classifier.test.ts +++ b/src/lib/adapters/tool-kind-classifier.test.ts @@ -102,6 +102,13 @@ describe("isAgentLikeToolName", () => { expect(isAgentLikeToolName("askuserquestion")).toBe(true) }) + it("matches Antigravity's bare ask_question history name only", () => { + expect(isAgentLikeToolName("ask_question")).toBe(true) + // A host-prefixed one is a third-party MCP tool (DeepWiki's, for one). + expect(isAgentLikeToolName("mcp__deepwiki__ask_question")).toBe(false) + expect(isAgentLikeToolName("deepwiki/ask_question")).toBe(false) + }) + it("matches check_user_feedback across host naming conventions", () => { expect(isAgentLikeToolName("check_user_feedback")).toBe(true) expect(isAgentLikeToolName("mcp__codeg-mcp__check_user_feedback")).toBe( diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index 753ea3b3ff..efc90bdc4c 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -20,7 +20,6 @@ const CANCEL_DELEGATION_SUFFIX_RE = /[^a-z0-9]cancel_delegation$/ const CREATE_GOAL_SUFFIX_RE = /[^a-z0-9]create_goal$/ const UPDATE_GOAL_SUFFIX_RE = /[^a-z0-9]update_goal$/ const ASK_USER_QUESTION_SUFFIX_RE = /[^a-z0-9]ask_user_question$/ -const ASK_QUESTION_SUFFIX_RE = /[^a-z0-9]ask_question$/ const CHECK_USER_FEEDBACK_SUFFIX_RE = /[^a-z0-9]check_user_feedback$/ /** @@ -65,6 +64,9 @@ export function isAgentLikeToolName(toolName: string): boolean { // forms, since this runs pre-normalize. name === "question" || name === "ask_user_question" || + // Google Antigravity's native ask tool: the history parser keeps this raw + // name. Bare only, like the alias in `tool-call-normalization` — a + // host-prefixed `*ask_question` is a third-party MCP tool. name === "ask_question" || // Kimi Code's native AskUserQuestion (also Claude's SDK tool of the same // name). The history parsers keep this raw camel-case name; the live path @@ -86,7 +88,6 @@ export function isAgentLikeToolName(toolName: string): boolean { if (CREATE_GOAL_SUFFIX_RE.test(name)) return true if (UPDATE_GOAL_SUFFIX_RE.test(name)) return true if (ASK_USER_QUESTION_SUFFIX_RE.test(name)) return true - if (ASK_QUESTION_SUFFIX_RE.test(name)) return true if (CHECK_USER_FEEDBACK_SUFFIX_RE.test(name)) return true if (CODEG_MCP_WORKBENCH_NAMES.has(name)) return true if (CODEG_MCP_WORKBENCH_SUFFIX_RE.test(name)) return true diff --git a/src/lib/ask-question.test.ts b/src/lib/ask-question.test.ts index 6745726fa7..30f96876c9 100644 --- a/src/lib/ask-question.test.ts +++ b/src/lib/ask-question.test.ts @@ -32,6 +32,111 @@ describe("splitRecommended", () => { recommended: false, }) }) + + // Google Antigravity's ask_question tells the model to PREFIX a recommended + // option with "(Recommended)". + it("strips a leading (Recommended) prefix, case-insensitively", () => { + expect(splitRecommended("(Recommended) Backend API")).toEqual({ + text: "Backend API", + recommended: true, + }) + expect(splitRecommended(" (recommended)核心业务功能")).toEqual({ + text: "核心业务功能", + recommended: true, + }) + }) +}) + +describe("Google Antigravity ask_question", () => { + // `arguments_json` and the history parser's envelope for the same call, + // recorded from antigravity-acp 1.4.1 (options are plain strings, the kind + // is snake_case `is_multi_select`, there is no header). + const input = JSON.stringify({ + questions: [ + { + is_multi_select: false, + options: [ + "(Recommended) Backend API", + "Frontend UI", + "Database schema", + ], + question: "Which area first?", + }, + { + is_multi_select: true, + options: ["Alpha", "Beta", "Gamma"], + question: "Pick tags", + }, + ], + toolAction: "Asking user", + toolSummary: "Priorities", + }) + + it("reads string options and is_multi_select", () => { + expect(parseAskQuestionInput(input)).toEqual([ + { + question: "Which area first?", + header: "", + multiSelect: false, + options: [ + { label: "(Recommended) Backend API", description: "" }, + { label: "Frontend UI", description: "" }, + { label: "Database schema", description: "" }, + ], + isSecret: false, + id: undefined, + }, + { + question: "Pick tags", + header: "", + multiSelect: true, + options: [ + { label: "Alpha", description: "" }, + { label: "Beta", description: "" }, + { label: "Gamma", description: "" }, + ], + isSecret: false, + id: undefined, + }, + ]) + }) + + it("matches the parser's answers back to the offered options", () => { + const questions = parseAskQuestionInput(input) + const outcome = parseAskQuestionOutcome( + JSON.stringify({ + answers: [ + { + header: "", + question: "Which area first?", + multi_select: false, + selected: ["(Recommended) Backend API"], + }, + { + header: "", + question: "Pick tags", + multi_select: true, + selected: ["Gamma"], + }, + ], + declined: false, + }), + questions + ) + expect(outcome?.declined).toBe(false) + expect( + matchSelections( + outcome?.answers[0].selected ?? [], + questions[0].options.map((o) => o.label) + ) + ).toEqual({ selected: ["(Recommended) Backend API"], other: [] }) + expect( + matchSelections( + outcome?.answers[1].selected ?? [], + questions[1].options.map((o) => o.label) + ) + ).toEqual({ selected: ["Gamma"], other: [] }) + }) }) describe("parseAskQuestionInput", () => { diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index a882c2ef04..2c97a35b07 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -590,6 +590,23 @@ describe("normalizeToolName collapses ask_user_question across hosts", () => { }) }) +describe("normalizeToolName maps Antigravity's native ask_question", () => { + it("collapses the bare name its history parser keeps", () => { + expect(normalizeToolName("ask_question")).toBe("question") + }) + + // A host-prefixed `ask_question` is a third-party MCP tool — DeepWiki's takes + // `{repoName, question}` — which the question card cannot render. + it.each([ + "mcp__deepwiki__ask_question", + "deepwiki/ask_question", + "deepwiki_ask_question", + "deepwiki: ask_question", + ])("leaves %s to its own card", (input) => { + expect(normalizeToolName(input)).not.toBe("question") + }) +}) + describe("normalizeToolName collapses check_user_feedback across hosts", () => { it.each([ "check_user_feedback", diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 30b009e50e..e62381ebc7 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -160,8 +160,11 @@ const EXACT_TOOL_NAME_ALIASES: Record = { question: "question", ask_user_question: "question", askuserquestion: "question", + // Google Antigravity's native ask tool, as its history parser names the call. + // Bare name only, on purpose: a host-prefixed `*ask_question` is someone + // else's MCP tool (DeepWiki's `ask_question` takes `{repoName, question}`), + // which the question card cannot render. ask_question: "question", - askquestion: "question", // codeg-mcp ask-user-question companion tool (server prefix varies by host; // the suffix rule in `normalizeToolName` covers the other separators) "mcp__codeg-mcp__ask_user_question": "question", @@ -625,7 +628,6 @@ function normalizeUncached(toolName: string): string { // catches the unprefixed form, so collapse every separator here. Note the // freeform matcher below intentionally does NOT catch the underscore form. if (/[^a-z0-9]ask_user_question$/.test(canonical)) return "question" - if (/[^a-z0-9]ask_question$/.test(canonical)) return "question" // codeg-mcp live-feedback poll. Same host-prefix story as the delegation tools // (`mcp____check_user_feedback`, `/check_user_feedback`, …) — From db1bb91950e63a78f97ad92ada531af3e0df91f6 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 14:36:44 +0800 Subject: [PATCH 8/8] docs: drop the AGENTS.md section unrelated to #1024 AGENTS.md mirrors CLAUDE.md; the added troubleshooting section (Windows MSVC rlib notes, personal worktree rules, a pointer to the new bridge) existed only in AGENTS.md and is not part of this fix. --- AGENTS.md | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 591bcd2f9c..52d68d4236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,51 +50,6 @@ cargo insta review INSTA_UPDATE=auto cargo test --features test-utils # 自动写新 .snap ``` -## 常见构建问题与避坑指南 (Troubleshooting) - -### 1. Windows MSVC 4GB `.rlib` 链接器限制 (LNK4003 / LNK2019 / LNK1120) - -- **现象**: - 在 Windows MSVC 环境下构建或测试(如 `cargo build --bin codeg`)时报错: - ```text - warning: LNK4003: 无效的库格式;已忽略库 '...\\libcodeg_lib-xxx.rlib' - error: linking with `link.exe` failed: exit code: 1120 - ... - libcodeg_lib-xxx.rlib : error LNK2019: 无法解析的外部符号 ... - ``` -- **根本原因**: - Rust 默认开发配置使用 `debug = 2` 生成完整 AST 和变量调试信息。项目依赖较多时,核心库 `libcodeg_lib.rlib` 体积会膨胀超过 4GB(例如 4.27 GB)。MSVC 的 COFF/PE 归档格式使用 32 位偏移寻址,超出 4GB 限制导致归档损坏,`link.exe` 判定格式无效而跳过链接,引发连锁未解析符号错误。 -- **解决方案(推荐方案)**: - 将 `debuginfo` 级别设为 1(保留文件名与行号,保持完整 panic 堆栈回溯,但不内嵌庞大的 AST 符号信息): - ```powershell - # 1. 设置环境变量(当前终端生效) - $env:RUSTFLAGS = "-C debuginfo=1" - - # 2. 删除已经损坏超过 4GB 的产物 - Remove-Item "src-tauri\target\debug\deps\*codeg_lib*" -Force -ErrorAction SilentlyContinue - - # 3. 重新构建 - cargo build --bin codeg - ``` - 设置 `debuginfo=1` 后,`libcodeg_lib.rlib` 体积会从 4.2GB+ 骤降至 200~300MB,链接顺利通过。 - 持久化方案亦可在 `src-tauri/.cargo/config.toml` 中配置: - ```toml - [profile.dev] - debug = 1 - ``` - -### 2. Git Worktree 与多 Agent 工作区隔离规范 - -- 为避免并行任务互相干扰,在多工作区(如 `worktree`)工作时: - 1. 必须始终在指定的物理工作目录执行文件读写与终端命令(禁止跨越至其他目录)。 - 2. 禁止在独立工作区切换共享分支或破坏其他工作区的构建缓存。 - 3. 若新建工作区缺少静态资源输出目录,Tauri 构建脚本可能检查 `..\out`,需确保 `out/` 占位目录存在。 - -### 3. Agent Client Protocol (ACP) 提问工具桥接 - -- Antigravity / Gemini CLI 原生提问工具(`ask_question` / `default_api:ask_question`)与标准 MCP 权限请求不同。 -- `src-tauri/src/acp/` 模块中提供了 `try_bridge_antigravity_ask` 机制,将其转化为交互式问题卡片并在前端呈现,而不是误当作普通工具执行权限请求拦截。后续扩展智能体原生交互协议时需遵循此桥接流程。 - ## 架构 ### 双模式运行