From 297b15a7b1144bcb4f42a90966f626efdebdd58e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:12:27 +0800 Subject: [PATCH] fix(agents): expose Cursor approval controls (#265) --- crates/ide-agent/src/config.rs | 35 ++++++++++++- crates/ide-agent/src/lib.rs | 40 ++++++++++++-- crates/ide-agent/src/tests.rs | 61 ++++++++++++++++++++++ src/features/agents/ConfigPickers.test.tsx | 34 ++++++++++++ src/features/agents/ConfigPickers.tsx | 13 +++-- src/features/agents/agents.test.ts | 2 + src/features/agents/composerModel.ts | 4 +- 7 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/features/agents/ConfigPickers.test.tsx diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs index bfff80bbb..9b0b52dcf 100644 --- a/crates/ide-agent/src/config.rs +++ b/crates/ide-agent/src/config.rs @@ -4,6 +4,39 @@ use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage}; use serde_json::{Value, json}; +/// Cursor exposes execution modes over ACP, but leaves approval policy to its client. +pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval"; + +pub(crate) fn add_client_options(provider: &str, config: &mut Vec) { + if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) { + return; + } + config.push(AgentConfigOption { + id: CURSOR_APPROVAL.into(), + name: "Approvals".into(), + description: Some("Tool approvals for this Cursor conversation".into()), + category: Some("approval".into()), + current: Some("ask".into()), + via: AgentConfigVia::Option, + values: vec![ + AgentConfigValue { + value: "ask".into(), + name: "Ask for approval".into(), + description: Some("Show permission requests from Cursor".into()), + }, + AgentConfigValue { + value: "bypass".into(), + name: "Bypass approvals".into(), + description: Some("Automatically approve tool requests in this conversation".into()), + }, + ], + }); +} + +pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool { + provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass")) +} + /// The settings in a `session/new` or `session/load` result: its select `configOptions`, /// plus the older `modes` / `models` fields for what those don't cover. pub(crate) fn options_of(result: &Value) -> Vec { @@ -64,7 +97,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec { pub(crate) fn merge_update(config: &mut Vec, options: &Value) { let fresh = parse_options(options); let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category); - config.retain(|o| o.via != AgentConfigVia::Option && !covered(o)); + config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o))); let legacy = std::mem::take(config); config.extend(fresh); config.extend(legacy); diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 621ab64d8..a1cdaeab3 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -114,7 +114,13 @@ impl Shared { } fn known_config(&self, provider: &str) -> Vec { - self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default() + let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default(); + config::add_client_options(provider, &mut config); + // Approval bypass belongs to the conversation, never to another new task. + if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) { + o.current = Some("ask".into()); + } + config } /// The commands the agent offered last time in this project. @@ -516,7 +522,7 @@ impl AgentManager { o.clone() }; let live = task.live.lock().clone().filter(|l| !l.peer.is_closed()); - if let Some(live) = live { + if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) { let (method, mut params) = config::set_request(&target, &value); params["sessionId"] = json!(live.session); let reply: Value = @@ -534,6 +540,7 @@ impl AgentManager { } s.task.model = model_of(&s.task.config).or(s.task.model.take()); } + task.approve_pending(); task.persist(); task.emit_task(); Ok(()) @@ -789,7 +796,8 @@ impl AgentManager { impl Task { fn restore(file: TaskFile, shared: Arc) -> Arc { - let task = file.task; + let mut task = file.task; + config::add_client_options(&task.provider, &mut task.config); let conversation = Conversation::restore(file.entries, file.subagents); Arc::new(Self { id: task.id.clone(), @@ -1174,10 +1182,17 @@ impl Task { let s = self.state.lock(); (if known { reported } else { s.task.config.clone() }, s.wanted.clone()) }; + config::add_client_options(&provider_id, &mut options); self.shared.remember(&provider_id, Some(&options), None); for choice in &wanted { // An option the session doesn't offer yet is set once it does (`apply_late_choices`). let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; + if o.id == config::CURSOR_APPROVAL { + if o.values.iter().any(|v| v.value == choice.value) { + options.iter_mut().find(|o| o.id == choice.id).unwrap().current = Some(choice.value.clone()); + } + continue; + } let already = known && o.current.as_deref() == Some(choice.value.as_str()); if already || !o.values.iter().any(|v| v.value == choice.value) { continue; @@ -1221,7 +1236,7 @@ impl Task { s.wanted .iter() .filter_map(|c| { - let o = s.task.config.iter().find(|o| o.id == c.id)?; + let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?; let due = o.current.as_deref() != Some(c.value.as_str()) && o.values.iter().any(|v| v.value == c.value) && sent.insert((c.id.clone(), c.value.clone())); @@ -1400,6 +1415,7 @@ impl Task { let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None }); self.permissions.lock().insert(id, tx); self.set_status(AgentStatus::NeedsInput, None); + self.approve_pending(); let answer = rx.recv().ok().flatten(); let name = answer @@ -1423,6 +1439,22 @@ impl Task { } } + fn approve_pending(&self) { + let s = self.state.lock(); + if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) { + return; + } + let mut pending = self.permissions.lock(); + for entry in s.conversation.main.entries.iter().rev() { + if let AgentEntry::Permission { id, options, chosen: None, .. } = entry + && let Some(option) = options.iter().find(|o| o.kind == "allow_once") + && let Some(reply) = pending.remove(id) + { + let _ = reply.send(Some(option.id.clone())); + } + } + } + fn cancel_permissions(&self) { for (_, reply) in self.permissions.lock().drain() { let _ = reply.send(None); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 670675734..f30fc2b5e 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -720,3 +720,64 @@ fn retry_updates_become_a_countdown() { ); assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt"); } + +#[tokio::test(flavor = "multi_thread")] +async fn cursor_approval_switch_resolves_pending_and_survives_restart() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let new = || NewAgentTask { + prompt: text("Fix"), + provider: "cursor".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }; + let task = mgr.create(new()).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!( + mgr.entries(&task.id, None) + .unwrap() + .iter() + .any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow")) + ); + // Agent config updates must not erase the client's approval setting. + mgr.prompt(&task.id, text("late option"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let t = mgr.task(&task.id).unwrap(); + assert!(config::bypass_approvals("cursor", &t.state.lock().task.config)); + t.kill(); + mgr.prompt(&task.id, text("Fix again"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass"))); + assert!(!config::bypass_approvals("cursor", &mgr.shared.known_config("cursor"))); + mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap(); + mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + mgr.cancel(&task.id, None).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // An unrelated new conversation still asks, even after the cache was updated. + let other = mgr.create(new()).await.unwrap(); + wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await; + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cursor_approval_choice_applies_before_first_prompt() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + prompt: text("Fix"), + provider: "cursor".into(), + config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + mgr.shutdown(); +} diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx new file mode 100644 index 000000000..635fdf068 --- /dev/null +++ b/src/features/agents/ConfigPickers.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import type { AgentConfigOption } from "@/bindings"; +import { ConfigPickers } from "./ConfigPickers"; + +it("shows Cursor approvals separately from its execution mode and sends the choice", () => { + const option = (id: string, category: string, values: string[]): AgentConfigOption => ({ + id, + category, + name: category === "approval" ? "Approvals" : "Mode", + description: null, + via: "option", + current: values[0]!, + values: values.map((value) => ({ + value, + name: value === "bypass" ? "Bypass approvals" : value, + description: null, + })), + }); + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Agent")).toBeVisible(); + fireEvent.click(screen.getByLabelText("Approvals")); + fireEvent.click(screen.getByRole("option", { name: "Bypass approvals" })); + expect(onChange).toHaveBeenCalledWith("openidea.cursor.approval", "bypass"); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index a7e13cb92..bcc562f61 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -1,6 +1,6 @@ /** - * Model / mode / thinking-effort dropdowns for a composer's bottom row. Like other agent - * UIs, only the mode gets icons (one per mode, by what it allows); levels are plain text. + * Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row. + * Modes and approvals use icons; levels are plain text. */ import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; @@ -34,7 +34,14 @@ export function ConfigPickers({ options={o.values.map((v) => ({ value: v.value, label: v.name, - icon: isMode ? modeIcon(v.value) : undefined, + icon: + o.category === "approval" + ? v.value === "bypass" + ? "unlock" + : "lock" + : isMode + ? modeIcon(v.value) + : undefined, detail: v.description ?? undefined, }))} onChange={(v) => onChange(o.id, v)} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index e130796e0..085a2b398 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -126,6 +126,8 @@ describe("agent composer", () => { const config = [option("fast", ["on"], null), option("mode", ["ask", "code"], "code"), option("model", ["m"], "m")]; expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode"]); expect(nextValue(config[1]!)).toBe("ask"); + const approvals = { ...option("openidea.cursor.approval", ["ask", "bypass"], "ask"), category: "approval" }; + expect(mainOptions([...config, approvals]).map((o) => o.id)).toEqual(["model", "mode", approvals.id]); expect( ["default", "acceptEdits", "plan", "auto", "bypassPermissions", "auto_edit", "read-only", "x"].map(modeIcon), ).toEqual(["lock", "edit", "todo", "run", "unlock", "edit", "eye", "lock"]); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index c030288df..f599c1bc1 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -109,9 +109,9 @@ export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean { return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n"); } -/** The settings shown as dropdowns: model, mode, thinking effort, in that order. */ +/** The settings shown as dropdowns: model, mode, approvals, thinking effort, in that order. */ export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] { - const order = ["model", "mode", "thought_level"]; + const order = ["model", "mode", "approval", "thought_level"]; return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1)); }