diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index ff4fa9753..848f46834 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -1692,12 +1692,17 @@ impl Task { /// Blocks the calling (blocking-pool) thread until the user answers. fn ask_permission(self: &Arc, params: &Value) -> Value { let id = self.next_permission.fetch_add(1, Ordering::Relaxed); + let (questions, by_index) = asked_questions(params); let title = params .pointer("/toolCall/title") .and_then(Value::as_str) .filter(|t| !t.is_empty()) .unwrap_or("The agent asks for permission") .to_owned(); + // An agent that answers by index titles its request "Ask user N question(s)"; its + // first question says what it wants, as the Claude Code adapter titles its own. + let title = + if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title }; // A subagent's request is answered in the task's conversation, named after the subagent. let session = params.get("sessionId").and_then(Value::as_str); let title = match session.and_then(|s| self.state.lock().conversation.label(s)) { @@ -1719,10 +1724,6 @@ impl Task { .collect() }) .unwrap_or_default(); - let questions: Vec = params - .pointer("/toolCall/_meta/questions") - .and_then(|questions| serde_json::from_value(questions.clone()).ok()) - .unwrap_or_default(); let asked: Vec = questions.iter().map(|question| question.question.clone()).collect(); let (tx, rx) = std::sync::mpsc::channel(); let entry = @@ -1736,7 +1737,7 @@ impl Task { .as_deref() .filter(|_| !asked.is_empty()) .and_then(|reply| serde_json::from_str::>(reply).ok()); - let name = match answers { + let name = match &answers { Some(answers) => asked .iter() .filter_map(|question| answers.get(question)) @@ -1762,6 +1763,31 @@ impl Task { if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput { self.set_status(AgentStatus::Running, None); } + // An agent that answers by index reads the answers beside the outcome, keyed by + // question index, with its Submit option chosen; anything less cancels the request. + if by_index { + let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first()); + return match (answers.as_ref(), submit) { + (Some(answers), Some(submit)) => { + let picked: serde_json::Map = asked + .iter() + .enumerate() + .filter_map(|(index, question)| { + let items = answers.get(question)?; + let answer = match items { + Value::Array(items) => { + items.iter().filter_map(Value::as_str).collect::>().join(", ") + } + item => item.as_str().unwrap_or_default().to_owned(), + }; + Some((index.to_string(), json!(answer))) + }) + .collect(); + json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked }) + } + _ => json!({ "outcome": { "outcome": "cancelled" } }), + }; + } match answer { Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }), None => json!({ "outcome": { "outcome": "cancelled" } }), @@ -1775,7 +1801,10 @@ impl Task { } let mut pending = self.permissions.lock(); for entry in s.conversation.main.entries.iter().rev() { - if let AgentEntry::Permission { id, options, chosen: None, .. } = entry + // A request with questions is never satisfied by a bypass: its Submit option + // would answer with no answers. + if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry + && questions.is_empty() && let Some(option) = options.iter().find(|o| o.kind == "allow_once") && let Some(reply) = pending.remove(id) { @@ -1840,6 +1869,23 @@ fn clean(mut prompt: AgentPrompt) -> Option { (!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt) } +/// The questions a permission request asks, and whether they answer by question index. +/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id; +/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside +/// the id of its Submit option. +fn asked_questions(params: &Value) -> (Vec, bool) { + for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] { + if let Some(questions) = params + .pointer(pointer) + .and_then(|questions| serde_json::from_value::>(questions.clone()).ok()) + .filter(|questions| !questions.is_empty()) + { + return (questions, by_index); + } + } + (Vec::new(), false) +} + /// ACP content blocks: the text, each file (embedded when its text is in `contents`, else /// a `resource_link`), then the images. fn blocks(prompt: &AgentPrompt, contents: &[Option]) -> Vec { diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index c0fff069c..6f8fed606 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -9,6 +9,7 @@ struct FakeAgent { /// Every connection's agent side, kept open. servers: Mutex>, calls: Mutex>, + permission_replies: Mutex>, /// Without `loadSession` (a session lives only as long as its process). no_load: bool, auth_on_new: bool, @@ -191,9 +192,37 @@ impl Handler for FakeAgent { None, )) .unwrap(); + self.permission_replies.lock().push(answer.clone()); let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); self.say(&session, &format!("answered {chosen}")); - return Ok(json!({"stopReason": "end_turn"})); + return Ok(json!({ "stopReason": "end_turn" })); + } + if text == "qwen ask" { + // Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and + // Cancel as the options, the answers keyed by question index. + let peer = self.latest(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, + "toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think", + "_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question", + "qwenQuestions": [ + {"question": "Which database?", "header": "Database", "options": [ + {"label": "Postgres", "description": "Relational"}, + {"label": "Redis", "description": "Key-value"}]}, + {"question": "Which caches?", "header": "Caches", "multiSelect": true, + "options": [{"label": "Disk"}, {"label": "Memory"}]}]}}, + "options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"}, + {"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}), + None, + )) + .unwrap(); + let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none"); + let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + let answers = answer.get("answers").cloned().unwrap_or(Value::Null); + self.say(&session, &format!("answered {outcome} {option} {answers}")); + return Ok(json!({ "stopReason": "end_turn" })); } if text == "wait" { // Works until cancelled. @@ -692,9 +721,10 @@ fn prompts_become_acp_content_blocks() { } #[tokio::test(flavor = "multi_thread")] -async fn answered_questions_close_the_question_with_every_picked_label() { +async fn claude_questions_keep_the_answer_object_as_the_option_id() { let tmp = tempfile::tempdir().unwrap(); - let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { project_root: None, @@ -712,13 +742,22 @@ async fn answered_questions_close_the_question_with_every_picked_label() { .unwrap() .iter() .find_map(|e| match e { - AgentEntry::Permission { id, .. } => Some(*id), + AgentEntry::Permission { id, title, questions, .. } => { + assert_eq!(title, "AskUserQuestion"); + assert_eq!(questions.len(), 2); + assert_eq!(questions[0].question, "Which layout?"); + assert!(!questions[0].multi_select); + assert_eq!(questions[1].question, "Which checks?"); + assert!(questions[1].multi_select); + Some(*id) + } _ => None, }) .unwrap(); let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string(); - mgr.respond(&task.id, question, Some(reply)).unwrap(); + mgr.respond(&task.id, question, Some(reply.clone())).unwrap(); wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]); let entries = mgr.entries(&task.id, None).unwrap(); assert!( @@ -729,6 +768,113 @@ async fn answered_questions_close_the_question_with_every_picked_label() { ); } +/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the +/// card answers with `answers` keyed by question index beside the Submit option's id. +#[tokio::test(flavor = "multi_thread")] +async fn qwen_questions_answer_by_index_beside_the_submit_option() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("qwen ask"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let (id, title, questions) = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())), + _ => None, + }) + .unwrap(); + assert_eq!(title, "Which database?", "the first question, not the request's own title"); + assert_eq!( + questions, + vec![ + AgentQuestion { + header: "Database".into(), + question: "Which database?".into(), + multi_select: false, + options: vec![ + AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None }, + AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None }, + ], + }, + AgentQuestion { + header: "Caches".into(), + question: "Which caches?".into(), + multi_select: true, + options: vec![ + AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None }, + AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None }, + ], + }, + ] + ); + let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string(); + mgr.respond(&task.id, id, Some(reply)).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id, None).unwrap(); + let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"})); + assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}"); + assert!( + entries + .iter() + .any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")), + "{entries:?}" + ); +} + +/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects +/// only beside a chosen option, and `reject` is none of the options it offered. +#[tokio::test(flavor = "multi_thread")] +async fn skipped_qwen_questions_cancel_the_request() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("qwen ask"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let id = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id), + _ => None, + }) + .unwrap(); + mgr.respond(&task.id, id, Some("reject".into())).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id, None).unwrap(); + assert!( + entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "answered cancelled none null")), + "{entries:?}" + ); + assert!( + entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")), + "{entries:?}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { let tmp = tempfile::tempdir().unwrap(); @@ -1222,6 +1368,64 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() { mgr.shutdown(); } +#[tokio::test(flavor = "multi_thread")] +async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() { + for prompt in ["ask", "qwen ask"] { + for bypass_at_start in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + // Cursor's client-side approval policy applies independently of the question dialect. + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text(prompt), + provider: "cursor".into(), + config: if bypass_at_start { + vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }] + } else { + vec![] + }, + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let id = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|entry| match entry { + AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id), + _ => None, + }) + .unwrap(); + mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap(); + assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}"); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput); + let reply = if prompt == "ask" { + json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]}) + } else { + json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]}) + }; + mgr.respond(&task.id, id, Some(reply.to_string())).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + mgr.prompt(&task.id, text("Fix"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let entries = mgr.entries(&task.id, None).unwrap(); + assert!( + entries.iter().any(|entry| matches!(entry, + AgentEntry::Permission { questions, chosen: Some(name), .. } + if questions.is_empty() && name == "Allow")), + "{entries:?}" + ); + assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes"))); + mgr.shutdown(); + } + } +} + #[tokio::test(flavor = "multi_thread")] async fn cursor_approval_choice_applies_before_first_prompt() { let tmp = tempfile::tempdir().unwrap();