Approvals for ACP-based executors (#1511)
* Approvals for ACP-based executors Gemini, Qwen, and Opencode * set all permissions to "ask" * use `normalize_unified_diff` in other log normalizers
This commit is contained in:
@@ -46,6 +46,11 @@
|
||||
"model": "gemini-3-pro-preview",
|
||||
"yolo": true
|
||||
}
|
||||
},
|
||||
"APPROVALS": {
|
||||
"GEMINI": {
|
||||
"yolo": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"CODEX": {
|
||||
@@ -78,11 +83,19 @@
|
||||
},
|
||||
"OPENCODE": {
|
||||
"DEFAULT": {
|
||||
"OPENCODE": {}
|
||||
"OPENCODE": {
|
||||
"auto_approve": true
|
||||
}
|
||||
},
|
||||
"PLAN": {
|
||||
"OPENCODE": {
|
||||
"mode": "plan"
|
||||
"mode": "plan",
|
||||
"auto_approve": true
|
||||
}
|
||||
},
|
||||
"APPROVALS": {
|
||||
"OPENCODE": {
|
||||
"auto_approve": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -91,6 +104,11 @@
|
||||
"QWEN_CODE": {
|
||||
"yolo": true
|
||||
}
|
||||
},
|
||||
"APPROVALS": {
|
||||
"QWEN_CODE": {
|
||||
"yolo": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"CURSOR_AGENT": {
|
||||
|
||||
@@ -49,6 +49,10 @@ impl ExecutionEnv {
|
||||
command.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.vars.contains_key(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
use agent_client_protocol as acp;
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::executors::acp::AcpEvent;
|
||||
use agent_client_protocol::{self as acp, ErrorCode};
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tracing::{debug, warn};
|
||||
use workspace_utils::approvals::ApprovalStatus;
|
||||
|
||||
use crate::{
|
||||
approvals::{ExecutorApprovalError, ExecutorApprovalService},
|
||||
executors::acp::{AcpEvent, ApprovalResponse},
|
||||
};
|
||||
|
||||
/// ACP client that handles agent-client protocol communication
|
||||
#[derive(Clone)]
|
||||
pub struct AcpClient {
|
||||
event_tx: mpsc::UnboundedSender<AcpEvent>,
|
||||
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||
feedback_queue: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl AcpClient {
|
||||
/// Create a new ACP client
|
||||
pub fn new(event_tx: mpsc::UnboundedSender<AcpEvent>) -> Self {
|
||||
Self { event_tx }
|
||||
pub fn new(
|
||||
event_tx: mpsc::UnboundedSender<AcpEvent>,
|
||||
approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event_tx,
|
||||
approvals,
|
||||
feedback_queue: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_user_prompt_event(&self, prompt: &str) {
|
||||
@@ -26,6 +42,21 @@ impl AcpClient {
|
||||
warn!("Failed to send ACP event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a user feedback message to be sent after a denial.
|
||||
pub async fn enqueue_feedback(&self, message: String) {
|
||||
let trimmed = message.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
let mut q = self.feedback_queue.lock().await;
|
||||
q.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain and return queued feedback messages.
|
||||
pub async fn drain_feedback(&self) -> Vec<String> {
|
||||
let mut q = self.feedback_queue.lock().await;
|
||||
q.drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
@@ -34,31 +65,107 @@ impl acp::Client for AcpClient {
|
||||
&self,
|
||||
args: acp::RequestPermissionRequest,
|
||||
) -> Result<acp::RequestPermissionResponse, acp::Error> {
|
||||
// Forward the request as an event
|
||||
self.send_event(AcpEvent::RequestPermission(args.clone()));
|
||||
|
||||
// Auto-approve with best available option
|
||||
let chosen_option = args
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowAlways))
|
||||
.or_else(|| {
|
||||
args.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce))
|
||||
})
|
||||
.or_else(|| args.options.first());
|
||||
if self.approvals.is_none() {
|
||||
// Auto-approve with best available option when no approval service is configured
|
||||
let chosen_option = args
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowAlways))
|
||||
.or_else(|| {
|
||||
args.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce))
|
||||
})
|
||||
.or_else(|| args.options.first());
|
||||
|
||||
let outcome = if let Some(opt) = chosen_option {
|
||||
debug!("Auto-approving permission with option: {}", opt.option_id);
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
opt.option_id.clone(),
|
||||
))
|
||||
} else {
|
||||
warn!("No permission options available, cancelling");
|
||||
acp::RequestPermissionOutcome::Cancelled
|
||||
let outcome = if let Some(opt) = chosen_option {
|
||||
debug!("Auto-approving permission with option: {}", opt.option_id);
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
opt.option_id.clone(),
|
||||
))
|
||||
} else {
|
||||
warn!("No permission options available, cancelling");
|
||||
acp::RequestPermissionOutcome::Cancelled
|
||||
};
|
||||
|
||||
return Ok(acp::RequestPermissionResponse::new(outcome));
|
||||
}
|
||||
|
||||
let tool_call_id = args.tool_call.tool_call_id.0.to_string();
|
||||
let status = match self
|
||||
.approvals
|
||||
.as_ref()
|
||||
.ok_or(ExecutorApprovalError::ServiceUnavailable)
|
||||
.map_err(|_| acp::Error::invalid_request())?
|
||||
.request_tool_approval(
|
||||
args.tool_call.fields.title.as_deref().unwrap_or("tool"),
|
||||
serde_json::json!({ "tool_call": args.tool_call }),
|
||||
&tool_call_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
warn!("Failed to request tool approval: {}", err);
|
||||
return Err(acp::Error::new(
|
||||
ErrorCode::INTERNAL_ERROR.code,
|
||||
format!("Approval request failed: {}", err),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Map our ApprovalStatus to ACP outcome
|
||||
let outcome = match &status {
|
||||
ApprovalStatus::Approved => {
|
||||
let chosen = args
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce));
|
||||
if let Some(opt) = chosen {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
opt.option_id.clone(),
|
||||
))
|
||||
} else {
|
||||
tracing::error!("No suitable approval option found, cancelling");
|
||||
return Err(acp::Error::invalid_request());
|
||||
}
|
||||
}
|
||||
ApprovalStatus::Denied { reason } => {
|
||||
// If user provided a reason, queue it to send after denial
|
||||
if let Some(feedback) = reason.as_ref() {
|
||||
self.enqueue_feedback(feedback.clone()).await;
|
||||
}
|
||||
let chosen = args
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| matches!(o.kind, acp::PermissionOptionKind::RejectOnce));
|
||||
if let Some(opt) = chosen {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
opt.option_id.clone(),
|
||||
))
|
||||
} else {
|
||||
warn!("No permission options for denial, cancelling");
|
||||
acp::RequestPermissionOutcome::Cancelled
|
||||
}
|
||||
}
|
||||
ApprovalStatus::TimedOut => {
|
||||
warn!("Approval timed out");
|
||||
acp::RequestPermissionOutcome::Cancelled
|
||||
}
|
||||
ApprovalStatus::Pending => {
|
||||
// This should not occur after waiter resolves
|
||||
warn!("Approval resolved to Pending");
|
||||
acp::RequestPermissionOutcome::Cancelled
|
||||
}
|
||||
};
|
||||
|
||||
self.send_event(AcpEvent::ApprovalResponse(ApprovalResponse {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
status: status.clone(),
|
||||
}));
|
||||
|
||||
Ok(acp::RequestPermissionResponse::new(outcome))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -14,10 +15,11 @@ use tokio_util::{
|
||||
io::ReaderStream,
|
||||
};
|
||||
use tracing::error;
|
||||
use workspace_utils::stream_lines::LinesStreamExt;
|
||||
use workspace_utils::{approvals::ApprovalStatus, stream_lines::LinesStreamExt};
|
||||
|
||||
use super::{AcpClient, SessionManager};
|
||||
use crate::{
|
||||
approvals::ExecutorApprovalService,
|
||||
command::{CmdOverrides, CommandParts},
|
||||
env::ExecutionEnv,
|
||||
executors::{ExecutorError, ExecutorExitResult, SpawnedChild, acp::AcpEvent},
|
||||
@@ -73,6 +75,7 @@ impl AcpAgentHarness {
|
||||
command_parts: CommandParts,
|
||||
env: &ExecutionEnv,
|
||||
cmd_overrides: &CmdOverrides,
|
||||
approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,
|
||||
) -> Result<SpawnedChild, ExecutorError> {
|
||||
let (program_path, args) = command_parts.into_resolved().await?;
|
||||
let mut command = Command::new(program_path);
|
||||
@@ -101,6 +104,7 @@ impl AcpAgentHarness {
|
||||
self.session_namespace.clone(),
|
||||
self.model.clone(),
|
||||
self.mode.clone(),
|
||||
approvals,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -111,6 +115,7 @@ impl AcpAgentHarness {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn spawn_follow_up_with_command(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
@@ -119,6 +124,7 @@ impl AcpAgentHarness {
|
||||
command_parts: CommandParts,
|
||||
env: &ExecutionEnv,
|
||||
cmd_overrides: &CmdOverrides,
|
||||
approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,
|
||||
) -> Result<SpawnedChild, ExecutorError> {
|
||||
let (program_path, args) = command_parts.into_resolved().await?;
|
||||
let mut command = Command::new(program_path);
|
||||
@@ -147,6 +153,7 @@ impl AcpAgentHarness {
|
||||
self.session_namespace.clone(),
|
||||
self.model.clone(),
|
||||
self.mode.clone(),
|
||||
approvals,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -167,6 +174,7 @@ impl AcpAgentHarness {
|
||||
session_namespace: String,
|
||||
model: Option<String>,
|
||||
mode: Option<String>,
|
||||
approvals: Option<std::sync::Arc<dyn ExecutorApprovalService>>,
|
||||
) -> Result<(), ExecutorError> {
|
||||
// Take child's stdio for ACP wiring
|
||||
let orig_stdout = child.inner().stdout.take().ok_or_else(|| {
|
||||
@@ -281,8 +289,9 @@ impl AcpAgentHarness {
|
||||
};
|
||||
let session_manager = std::sync::Arc::new(session_manager);
|
||||
|
||||
// Create ACP client
|
||||
let client = AcpClient::new(event_tx.clone());
|
||||
// Create ACP client with approvals support
|
||||
let client = AcpClient::new(event_tx.clone(), approvals.clone());
|
||||
let client_feedback_handle = client.clone();
|
||||
|
||||
client.record_user_prompt_event(&prompt);
|
||||
|
||||
@@ -291,6 +300,7 @@ impl AcpAgentHarness {
|
||||
proto::ClientSideConnection::new(client, outgoing, incoming, |fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
});
|
||||
let conn = Rc::new(conn);
|
||||
|
||||
// Drive I/O
|
||||
let io_handle = tokio::task::spawn_local(async move {
|
||||
@@ -382,13 +392,30 @@ impl AcpAgentHarness {
|
||||
let app_tx_clone = log_tx.clone();
|
||||
let sess_id_for_writer = display_session_id.clone();
|
||||
let sm_for_writer = session_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
let conn_for_cancel = conn.clone();
|
||||
let acp_session_id_for_cancel = acp_session_id.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
if let AcpEvent::ApprovalResponse(resp) = &event
|
||||
&& let ApprovalStatus::Denied {
|
||||
reason: Some(reason),
|
||||
} = &resp.status
|
||||
&& !reason.trim().is_empty()
|
||||
{
|
||||
let _ = conn_for_cancel
|
||||
.cancel(proto::CancelNotification::new(
|
||||
proto::SessionId::new(
|
||||
acp_session_id_for_cancel.clone(),
|
||||
),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let line = event.to_string();
|
||||
// Forward to stdout
|
||||
let _ = app_tx_clone.send(event.to_string());
|
||||
let _ = app_tx_clone.send(line.clone());
|
||||
// Persist to session file
|
||||
let _ = sm_for_writer
|
||||
.append_raw_line(&sess_id_for_writer, &event.to_string());
|
||||
let _ = sm_for_writer.append_raw_line(&sess_id_for_writer, &line);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -400,35 +427,61 @@ impl AcpAgentHarness {
|
||||
);
|
||||
|
||||
// Build prompt request
|
||||
let req = proto::PromptRequest::new(
|
||||
let initial_req = proto::PromptRequest::new(
|
||||
proto::SessionId::new(acp_session_id.clone()),
|
||||
vec![proto::ContentBlock::Text(proto::TextContent::new(
|
||||
prompt_to_send,
|
||||
))],
|
||||
);
|
||||
|
||||
// Send the prompt and await completion to obtain stop_reason
|
||||
match conn.prompt(req).await {
|
||||
Ok(resp) => {
|
||||
// Emit done with stop_reason
|
||||
let stop_reason =
|
||||
serde_json::to_string(&resp.stop_reason).unwrap_or_default();
|
||||
let _ = log_tx.send(AcpEvent::Done(stop_reason).to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("error {} {e} {:?}", e.code, e.data);
|
||||
if e.code == agent_client_protocol::ErrorCode::INTERNAL_ERROR.code
|
||||
&& e.data
|
||||
.as_ref()
|
||||
.is_some_and(|d| d == "server shut down unexpectedly")
|
||||
{
|
||||
tracing::debug!("ACP server killed");
|
||||
} else {
|
||||
let _ =
|
||||
log_tx.send(AcpEvent::Error(format!("{e}")).to_string());
|
||||
let mut current_req = Some(initial_req);
|
||||
|
||||
while let Some(req) = current_req.take() {
|
||||
tracing::trace!(?req, "sending ACP prompt request");
|
||||
// Send the prompt and await completion to obtain stop_reason
|
||||
match conn.prompt(req).await {
|
||||
Ok(resp) => {
|
||||
// Emit done with stop_reason
|
||||
let stop_reason = serde_json::to_string(&resp.stop_reason)
|
||||
.unwrap_or_default();
|
||||
let _ = log_tx.send(AcpEvent::Done(stop_reason).to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("error {} {e} {:?}", e.code, e.data);
|
||||
if e.code
|
||||
== agent_client_protocol::ErrorCode::INTERNAL_ERROR.code
|
||||
&& e.data
|
||||
.as_ref()
|
||||
.is_some_and(|d| d == "server shut down unexpectedly")
|
||||
{
|
||||
tracing::debug!("ACP server killed");
|
||||
} else {
|
||||
let _ = log_tx
|
||||
.send(AcpEvent::Error(format!("{e}")).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any pending user feedback after finish
|
||||
let feedback = client_feedback_handle
|
||||
.drain_feedback()
|
||||
.await
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !feedback.is_empty() {
|
||||
tracing::trace!(?feedback, "sending ACP follow-up feedback");
|
||||
let session_id = proto::SessionId::new(acp_session_id.clone());
|
||||
let feedback_req = proto::PromptRequest::new(
|
||||
session_id.clone(),
|
||||
vec![proto::ContentBlock::Text(proto::TextContent::new(
|
||||
feedback,
|
||||
))],
|
||||
);
|
||||
current_req = Some(feedback_req);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify container of completion
|
||||
if let Some(tx) = exit_signal_tx.take() {
|
||||
let _ = tx.send(ExecutorExitResult::Success);
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use harness::AcpAgentHarness;
|
||||
pub use normalize_logs::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use session::SessionManager;
|
||||
use workspace_utils::approvals::ApprovalStatus;
|
||||
|
||||
/// Parsed event types for internal processing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -24,6 +25,7 @@ pub enum AcpEvent {
|
||||
AvailableCommands(Vec<agent_client_protocol::AvailableCommand>),
|
||||
CurrentMode(agent_client_protocol::SessionModeId),
|
||||
RequestPermission(agent_client_protocol::RequestPermissionRequest),
|
||||
ApprovalResponse(ApprovalResponse),
|
||||
Error(String),
|
||||
Done(String),
|
||||
Other(agent_client_protocol::SessionNotification),
|
||||
@@ -42,3 +44,9 @@ impl FromStr for AcpEvent {
|
||||
serde_json::from_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApprovalResponse {
|
||||
pub tool_call_id: String,
|
||||
pub status: ApprovalStatus,
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ use agent_client_protocol::{self as acp, SessionNotification};
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use workspace_utils::msg_store::MsgStore;
|
||||
use workspace_utils::{approvals::ApprovalStatus, msg_store::MsgStore};
|
||||
|
||||
pub use super::AcpAgentHarness;
|
||||
use super::AcpEvent;
|
||||
use crate::logs::{
|
||||
ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem,
|
||||
ToolResult, ToolResultValueType, ToolStatus as LogToolStatus,
|
||||
stderr_processor::normalize_stderr_logs,
|
||||
utils::{ConversationPatch, EntryIndexProvider},
|
||||
use crate::{
|
||||
approvals::ToolCallMetadata,
|
||||
logs::{
|
||||
ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType,
|
||||
TodoItem, ToolResult, ToolResultValueType, ToolStatus as LogToolStatus,
|
||||
stderr_processor::normalize_stderr_logs,
|
||||
utils::{ConversationPatch, EntryIndexProvider},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn normalize_logs(msg_store: Arc<MsgStore>, worktree_path: &Path) {
|
||||
@@ -220,6 +223,35 @@ pub fn normalize_logs(msg_store: Arc<MsgStore>, worktree_path: &Path) {
|
||||
tracing::debug!("Failed to convert tool call update to ToolCall");
|
||||
}
|
||||
}
|
||||
AcpEvent::ApprovalResponse(resp) => {
|
||||
tracing::trace!("Received approval response: {:?}", resp);
|
||||
if let ApprovalStatus::Denied { reason } = resp.status {
|
||||
let tool_name = tool_states
|
||||
.get(&resp.tool_call_id)
|
||||
.map(|t| {
|
||||
extract_tool_name_from_id(t.id.0.as_ref())
|
||||
.unwrap_or_else(|| t.title.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let idx = entry_index.next();
|
||||
let entry = NormalizedEntry {
|
||||
timestamp: None,
|
||||
entry_type: NormalizedEntryType::UserFeedback {
|
||||
denied_tool: tool_name,
|
||||
},
|
||||
content: reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
"User denied this tool use request".to_string()
|
||||
})
|
||||
.trim()
|
||||
.to_string(),
|
||||
metadata: None,
|
||||
};
|
||||
msg_store
|
||||
.push_patch(ConversationPatch::add_normalized_entry(idx, entry));
|
||||
}
|
||||
}
|
||||
AcpEvent::User(_) | AcpEvent::Other(_) => (),
|
||||
}
|
||||
}
|
||||
@@ -251,7 +283,10 @@ pub fn normalize_logs(msg_store: Arc<MsgStore>, worktree_path: &Path) {
|
||||
status: convert_tool_status(&tool_data.status),
|
||||
},
|
||||
content: get_tool_content(tool_data),
|
||||
metadata: None,
|
||||
metadata: serde_json::to_value(ToolCallMetadata {
|
||||
tool_call_id: tool_data.id.0.to_string(),
|
||||
})
|
||||
.ok(),
|
||||
};
|
||||
let patch = if is_new {
|
||||
ConversationPatch::add_normalized_entry(tool_data.index, entry)
|
||||
@@ -461,6 +496,31 @@ pub fn normalize_logs(msg_store: Arc<MsgStore>, worktree_path: &Path) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if changes.is_empty()
|
||||
&& let Some(raw) = &tc.raw_input
|
||||
&& let Ok(edit_input) = serde_json::from_value::<EditInput>(raw.clone())
|
||||
{
|
||||
if let Some(diff) = edit_input.diff {
|
||||
changes.push(FileChange::Edit {
|
||||
unified_diff: workspace_utils::diff::normalize_unified_diff(
|
||||
&edit_input.file_path,
|
||||
&diff,
|
||||
),
|
||||
has_line_numbers: true,
|
||||
});
|
||||
} else if let Some(old) = edit_input.old_string
|
||||
&& let Some(new) = edit_input.new_string
|
||||
{
|
||||
changes.push(FileChange::Edit {
|
||||
unified_diff: workspace_utils::diff::create_unified_diff(
|
||||
&edit_input.file_path,
|
||||
&old,
|
||||
&new,
|
||||
),
|
||||
has_line_numbers: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
@@ -687,3 +747,15 @@ struct StreamingText {
|
||||
index: usize,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EditInput {
|
||||
file_path: String,
|
||||
#[serde(default)]
|
||||
diff: Option<String>,
|
||||
#[serde(default)]
|
||||
old_string: Option<String>,
|
||||
#[serde(default)]
|
||||
new_string: Option<String>,
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ impl SessionManager {
|
||||
| AcpEvent::ToolUpdate(..)
|
||||
| AcpEvent::Plan(..)
|
||||
| AcpEvent::AvailableCommands(..)
|
||||
| AcpEvent::ApprovalResponse(..)
|
||||
| AcpEvent::CurrentMode(..) => {}
|
||||
|
||||
AcpEvent::RequestPermission(req) => event = AcpEvent::ToolUpdate(req.tool_call),
|
||||
|
||||
@@ -26,9 +26,7 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use workspace_utils::{
|
||||
approvals::ApprovalStatus,
|
||||
diff::{concatenate_diff_hunks, extract_unified_diff_hunks},
|
||||
msg_store::MsgStore,
|
||||
approvals::ApprovalStatus, diff::normalize_unified_diff, msg_store::MsgStore,
|
||||
path::make_path_relative,
|
||||
};
|
||||
|
||||
@@ -337,8 +335,7 @@ fn normalize_file_changes(
|
||||
make_path_relative(dest.to_string_lossy().as_ref(), worktree_path);
|
||||
edits.push(FileChange::Rename { new_path: dest_rel });
|
||||
}
|
||||
let hunks = extract_unified_diff_hunks(unified_diff);
|
||||
let diff = concatenate_diff_hunks(&relative, &hunks);
|
||||
let diff = normalize_unified_diff(&relative, unified_diff);
|
||||
edits.push(FileChange::Edit {
|
||||
unified_diff: diff,
|
||||
has_line_numbers: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
use ts_rs::TS;
|
||||
use workspace_utils::{
|
||||
diff::{concatenate_diff_hunks, create_unified_diff, extract_unified_diff_hunks},
|
||||
diff::{create_unified_diff, normalize_unified_diff},
|
||||
msg_store::MsgStore,
|
||||
path::make_path_relative,
|
||||
shell::resolve_executable_path_blocking,
|
||||
@@ -734,9 +734,8 @@ impl CursorToolCall {
|
||||
let mut changes = vec![];
|
||||
|
||||
if let Some(apply_patch) = &args.apply_patch {
|
||||
let hunks = extract_unified_diff_hunks(&apply_patch.patch_content);
|
||||
changes.push(FileChange::Edit {
|
||||
unified_diff: concatenate_diff_hunks(&path, &hunks),
|
||||
unified_diff: normalize_unified_diff(&path, &apply_patch.patch_content),
|
||||
has_line_numbers: false,
|
||||
});
|
||||
}
|
||||
@@ -774,9 +773,8 @@ impl CursorToolCall {
|
||||
..
|
||||
})) = &result
|
||||
{
|
||||
let hunks = extract_unified_diff_hunks(diff_string);
|
||||
changes.push(FileChange::Edit {
|
||||
unified_diff: concatenate_diff_hunks(&path, &hunks),
|
||||
unified_diff: normalize_unified_diff(&path, diff_string),
|
||||
has_line_numbers: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ use futures::{StreamExt, future::ready};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use workspace_utils::{
|
||||
diff::{concatenate_diff_hunks, extract_unified_diff_hunks},
|
||||
msg_store::MsgStore,
|
||||
path::make_path_relative,
|
||||
diff::normalize_unified_diff, msg_store::MsgStore, path::make_path_relative,
|
||||
};
|
||||
|
||||
use crate::logs::{
|
||||
@@ -767,9 +765,8 @@ fn parse_apply_patch_result(value: &Value, worktree_path: &str) -> Option<Action
|
||||
let relative_path = make_path_relative(&file_path, worktree_path);
|
||||
|
||||
let changes = if let Some(diff_text) = diff {
|
||||
let hunks = extract_unified_diff_hunks(&diff_text);
|
||||
vec![FileChange::Edit {
|
||||
unified_diff: concatenate_diff_hunks(&relative_path, &hunks),
|
||||
unified_diff: normalize_unified_diff(&relative_path, &diff_text),
|
||||
has_line_numbers: true,
|
||||
}]
|
||||
} else if let Some(content_text) = content {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derivative::Derivative;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -8,6 +9,7 @@ use workspace_utils::msg_store::MsgStore;
|
||||
|
||||
pub use super::acp::AcpAgentHarness;
|
||||
use crate::{
|
||||
approvals::ExecutorApprovalService,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
env::ExecutionEnv,
|
||||
executors::{
|
||||
@@ -15,7 +17,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]
|
||||
#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct Gemini {
|
||||
#[serde(default)]
|
||||
pub append_prompt: AppendPrompt,
|
||||
@@ -25,6 +28,10 @@ pub struct Gemini {
|
||||
pub yolo: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
@@ -49,6 +56,10 @@ impl Gemini {
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for Gemini {
|
||||
fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {
|
||||
self.approvals = Some(approvals);
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
@@ -58,8 +69,20 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
let harness = AcpAgentHarness::new();
|
||||
let combined_prompt = self.append_prompt.combine_prompt(prompt);
|
||||
let gemini_command = self.build_command_builder().build_initial()?;
|
||||
let approvals = if self.yolo.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
harness
|
||||
.spawn_with_command(current_dir, combined_prompt, gemini_command, env, &self.cmd)
|
||||
.spawn_with_command(
|
||||
current_dir,
|
||||
combined_prompt,
|
||||
gemini_command,
|
||||
env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -73,6 +96,11 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
let harness = AcpAgentHarness::new();
|
||||
let combined_prompt = self.append_prompt.combine_prompt(prompt);
|
||||
let gemini_command = self.build_command_builder().build_follow_up(&[])?;
|
||||
let approvals = if self.yolo.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
harness
|
||||
.spawn_follow_up_with_command(
|
||||
current_dir,
|
||||
@@ -81,6 +109,7 @@ impl StandardCodingAgentExecutor for Gemini {
|
||||
gemini_command,
|
||||
env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derivative::Derivative;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use workspace_utils::msg_store::MsgStore;
|
||||
|
||||
use crate::{
|
||||
approvals::ExecutorApprovalService,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
env::ExecutionEnv,
|
||||
executors::{
|
||||
@@ -15,7 +17,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]
|
||||
#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct Opencode {
|
||||
#[serde(default)]
|
||||
pub append_prompt: AppendPrompt,
|
||||
@@ -23,13 +26,20 @@ pub struct Opencode {
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "agent")]
|
||||
pub mode: Option<String>,
|
||||
/// Auto-approve agent actions
|
||||
#[serde(default = "default_to_true")]
|
||||
pub auto_approve: bool,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||
}
|
||||
|
||||
impl Opencode {
|
||||
fn build_command_builder(&self) -> CommandBuilder {
|
||||
let builder = CommandBuilder::new("npx -y opencode-ai@1.0.134 acp");
|
||||
let builder = CommandBuilder::new("npx -y opencode-ai@1.0.134").extend_params(["acp"]);
|
||||
apply_overrides(builder, &self.cmd)
|
||||
}
|
||||
|
||||
@@ -40,6 +50,10 @@ impl Opencode {
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for Opencode {
|
||||
fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {
|
||||
self.approvals = Some(approvals);
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
@@ -56,13 +70,20 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
harness = harness.with_mode(agent);
|
||||
}
|
||||
let opencode_command = self.build_command_builder().build_initial()?;
|
||||
let approvals = if self.auto_approve {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
let env = setup_approvals_env(self.auto_approve, env);
|
||||
harness
|
||||
.spawn_with_command(
|
||||
current_dir,
|
||||
combined_prompt,
|
||||
opencode_command,
|
||||
env,
|
||||
&env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -83,14 +104,21 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
harness = harness.with_mode(agent);
|
||||
}
|
||||
let opencode_command = self.build_command_builder().build_follow_up(&[])?;
|
||||
let approvals = if self.auto_approve {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
let env = setup_approvals_env(self.auto_approve, env);
|
||||
harness
|
||||
.spawn_follow_up_with_command(
|
||||
current_dir,
|
||||
combined_prompt,
|
||||
session_id,
|
||||
opencode_command,
|
||||
env,
|
||||
&env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -127,3 +155,15 @@ impl StandardCodingAgentExecutor for Opencode {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_to_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn setup_approvals_env(auto_approve: bool, env: &ExecutionEnv) -> ExecutionEnv {
|
||||
let mut env = env.clone();
|
||||
if !auto_approve && !env.contains_key("OPENCODE_PERMISSION") {
|
||||
env.insert("OPENCODE_PERMISSION", r#"{"edit": "ask", "bash": "ask", "webfetch": "ask", "doom_loop": "ask", "external_directory": "ask"}"#);
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derivative::Derivative;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use workspace_utils::msg_store::MsgStore;
|
||||
|
||||
use crate::{
|
||||
approvals::ExecutorApprovalService,
|
||||
command::{CmdOverrides, CommandBuilder, apply_overrides},
|
||||
env::ExecutionEnv,
|
||||
executors::{
|
||||
@@ -15,7 +17,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]
|
||||
#[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct QwenCode {
|
||||
#[serde(default)]
|
||||
pub append_prompt: AppendPrompt,
|
||||
@@ -23,6 +26,10 @@ pub struct QwenCode {
|
||||
pub yolo: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub cmd: CmdOverrides,
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub approvals: Option<Arc<dyn ExecutorApprovalService>>,
|
||||
}
|
||||
|
||||
impl QwenCode {
|
||||
@@ -39,6 +46,10 @@ impl QwenCode {
|
||||
|
||||
#[async_trait]
|
||||
impl StandardCodingAgentExecutor for QwenCode {
|
||||
fn use_approvals(&mut self, approvals: Arc<dyn ExecutorApprovalService>) {
|
||||
self.approvals = Some(approvals);
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
@@ -48,8 +59,20 @@ impl StandardCodingAgentExecutor for QwenCode {
|
||||
let qwen_command = self.build_command_builder().build_initial()?;
|
||||
let combined_prompt = self.append_prompt.combine_prompt(prompt);
|
||||
let harness = AcpAgentHarness::with_session_namespace("qwen_sessions");
|
||||
let approvals = if self.yolo.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
harness
|
||||
.spawn_with_command(current_dir, combined_prompt, qwen_command, env, &self.cmd)
|
||||
.spawn_with_command(
|
||||
current_dir,
|
||||
combined_prompt,
|
||||
qwen_command,
|
||||
env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -63,6 +86,11 @@ impl StandardCodingAgentExecutor for QwenCode {
|
||||
let qwen_command = self.build_command_builder().build_follow_up(&[])?;
|
||||
let combined_prompt = self.append_prompt.combine_prompt(prompt);
|
||||
let harness = AcpAgentHarness::with_session_namespace("qwen_sessions");
|
||||
let approvals = if self.yolo.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
self.approvals.clone()
|
||||
};
|
||||
harness
|
||||
.spawn_follow_up_with_command(
|
||||
current_dir,
|
||||
@@ -71,6 +99,7 @@ impl StandardCodingAgentExecutor for QwenCode {
|
||||
qwen_command,
|
||||
env,
|
||||
&self.cmd,
|
||||
approvals,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user