дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(agents): expose Cursor approval controls (#265)
Цей коміт міститься в:
1 джерело
97144421d2
коміт
297b15a7b1
7 files changed
+179
-10
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача