diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d6bb5b44af..8a05c09296 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,142 @@ async fn try_bridge_pi_select_ask( Ok(()) } +/// 将 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<( + Arc, + crate::acp::question::QuestionRuntimeConfig, + )>, + connection_id: &str, + state: &Arc>, + emitter: &EventEmitter, + perms: &PendingPermissions, + req: &RequestPermissionRequest, + responder: Responder, +) -> Result<(), Responder> { + 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 = 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(ask) = crate::acp::question::parse_antigravity_ask(&tool_call, &options) else { + 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); + }; + + let (abort_tx, abort_rx) = oneshot::channel(); + perms.lock().await.park_detached(abort_tx); + + let Some(registered) = questions + .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; + + tokio::spawn(async move { + let answer = tokio::select! { + biased; + answered = registered.answer_rx => answered.ok(), + _ = abort_rx => None, + }; + 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, + } + }; + // 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) => { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)) + } + None => 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 @@ -17987,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 @@ -21475,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`). @@ -41609,6 +41840,330 @@ 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), + // 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(); + 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); + } + } + } + + /// 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 390b820068..4fdd1b42f8 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -701,6 +701,110 @@ 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 { + pub spec: QuestionSpec, + pub option_ids: Vec<(String, String)>, +} + +/// 识别 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 !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) { + if kind != "other" { + return None; + } + } + if let Some(status) = tool_call.get("status").and_then(Value::as_str) { + if status != "pending" { + return None; + } + } + 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 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; + } + 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, + }) +} + +/// 只回传本题的一个原生选项;取消或无法表达的自由文本不伪装为首项。 +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` /// — 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 +3719,95 @@ mod tests { assert_eq!(input["questions"][0]["question"], output["answers"][0]["question"]); assert_eq!(output["answers"][0]["header"], ""); } + fn antigravity_interaction() -> Value { + json!({ + "toolCallId": "interaction_396e46cc", + "status": "pending", + "title": "关于接下来的开发工作,您希望优先聚焦于哪个核心方向?", + "rawInput": {}, + "kind": "other" + }) + } + + fn antigravity_choices() -> Vec<(String, String)> { + vec![ + ("opaque-first".into(), "后端开发".into()), + ("opaque-second".into(), "前端开发".into()), + ] + } + + #[test] + 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 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 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/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, diff --git a/src-tauri/src/parsers/antigravity.rs b/src-tauri/src/parsers/antigravity.rs index 19d1620775..68240f3f2f 100644 --- a/src-tauri/src/parsers/antigravity.rs +++ b/src-tauri/src/parsers/antigravity.rs @@ -419,6 +419,45 @@ struct ErrorDetails { full_error: String, } +/// `exa.cortex_pb.AskQuestionOption`. +#[derive(Clone, PartialEq, ::prost::Message)] +struct AntigravityAskOption { + #[prost(string, tag = "1")] + id: String, + #[prost(string, tag = "2")] + 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")] + items: Vec, +} + #[derive(Clone, PartialEq, ::prost::Message)] struct ErrorMessageStep { #[prost(message, optional, tag = "3")] @@ -465,6 +504,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 +1357,58 @@ 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 mut selected: Vec = item + .chosen_ids + .iter() + .map(|id| { + item.options + .iter() + .find(|opt| &opt.id == id) + .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": item.is_multi_select, + "selected": selected, + }) + }) + .collect(); + // 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, + }); + } + if let Some(err) = step.error_message.as_ref() { let message = err .error @@ -2106,4 +2199,162 @@ mod tests { .join("conversations") ); } + + /// 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, + 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 }), + ..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) 核心业务功能的新增与实现"], + }, + { + "header": "", + "question": "在开发交付与流程配合上,您倾向于哪种模式?", + "multi_select": false, + "selected": ["阶段性里程碑交付,按完整模块验收"], + }, + ], + "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 }) + ); + + 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!([])); + } } 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 fa370d051f..efc90bdc4c 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -64,6 +64,10 @@ 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 // already collapses it to "question". 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/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.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 c9d71b25f3..e62381ebc7 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -160,6 +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", // 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",