fix(agents): expose Cursor approval controls (#265)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 19:12:27 +08:00
1 джерело 97144421d2
коміт 297b15a7b1
7 files changed
+179 -10

No files matched your search

+34 -1
Переглянути файл
@@ -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<AgentConfigOption>) {
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<AgentConfigOption> {
@@ -64,7 +97,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, 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);
+36 -4
Переглянути файл
@@ -114,7 +114,13 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
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<Shared>) -> Arc<Self> {
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);
+61
Переглянути файл
@@ -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();
}
+34
Переглянути файл
@@ -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(
<ConfigPickers
config={[
option("mode", "mode", ["Agent", "Plan", "Ask"]),
option("openidea.cursor.approval", "approval", ["ask", "bypass"]),
]}
onChange={onChange}
/>,
);
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");
});
+10 -3
Переглянути файл
@@ -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)}
+2
Переглянути файл
@@ -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"]);
+2 -2
Переглянути файл
@@ -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));
}