diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 6c428f34b..2b02ecc04 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -2296,11 +2296,15 @@ impl DelegationBroker { // Pull per-agent overrides from the broker config (defaults to empty). // Cloning is cheap — `AgentDelegationDefaults` is at most one Option // and a small BTreeMap, and the spawner consumes both fields by value. - let (preferred_mode_id, preferred_config_values) = cfg + let (configured_mode_id, preferred_config_values) = cfg .agent_defaults .get(&req.agent_type) .map(|d: &AgentDelegationDefaults| (d.mode_id.clone(), d.config_values.clone())) .unwrap_or((None, BTreeMap::new())); + // A per-call `permission_mode` wins over the settings default; when the + // LLM omits it the configured default is used unchanged, so existing + // callers and non-delegated sessions see no behaviour change. + let preferred_mode_id = req.permission_mode.clone().or(configured_mode_id); // Checkpoint #1 (opportunistic): if a parent cancel already landed // during the claim/depth phase, bail before spawning a child the parent // has abandoned. No child exists yet, so there's nothing to tear down. @@ -3757,6 +3761,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, } } @@ -4437,6 +4442,106 @@ mod tests { } } + /// A per-call `permission_mode` overrides the configured per-agent default + /// for that delegation only. This is the whole point of the parameter: a + /// parent can bound one child without changing global settings. + #[tokio::test] + async fn per_call_permission_mode_overrides_agent_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: Some("auto".into()), + config_values: BTreeMap::new(), + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.permission_mode = Some("plan".into()); + let _ = broker.handle_request(req).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("plan")); + } + + /// Omitting `permission_mode` must leave the configured default untouched, + /// so existing callers see no behaviour change. + #[tokio::test] + async fn omitted_permission_mode_keeps_configured_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: Some("auto".into()), + config_values: BTreeMap::new(), + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + // `request()` leaves permission_mode as None. + let _ = broker.handle_request(request(1, "pt-1")).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("auto")); + } + + /// With no configured default and no per-call value, nothing is forced. + #[tokio::test] + async fn per_call_permission_mode_works_without_any_agent_default() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.permission_mode = Some("plan".into()); + let _ = broker.handle_request(req).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("plan")); + } + #[tokio::test] async fn agent_defaults_are_forwarded_to_spawner() { // Configure broker with per-agent defaults for ClaudeCode and verify diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index f407c33ba..8d0c38f49 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -631,6 +631,17 @@ impl DelegationListener { .clone() .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + // Optional per-call session mode. Blank/whitespace is treated as + // omitted so a model emitting `""` cannot clear the configured + // default by accident. + let permission_mode = req + .input + .get("permission_mode") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let delegation_req = DelegationRequest { parent_connection_id: req.parent_connection_id, parent_conversation_id, @@ -639,6 +650,7 @@ impl DelegationListener { task, working_dir, requested_working_dir, + permission_mode, external_handle: req.external_handle, }; self.broker.start_delegation(delegation_req).await @@ -1337,6 +1349,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await; @@ -1490,6 +1503,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await @@ -1592,6 +1606,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, }) .await; @@ -1643,6 +1658,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: Some("h-1".into()), }; broker.handle_request(req).await diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 08002aaf4..7ecf88a17 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -32,6 +32,10 @@ "working_dir": { "type": "string", "description": "Absolute path the sub-agent runs in. Defaults to this session's working directory." + }, + "permission_mode": { + "type": "string", + "description": "Optional. Session mode the sub-agent starts in, for THIS delegation only. Use it to bound what a delegated agent may do without being asked, for example keeping it on a prompting or approval mode instead of running everything unattended. The value is the target agent's own session mode id, the same one shown in that agent's mode selector and used by the per-agent delegation default in Settings. Omit to keep the configured default, so existing callers are unaffected. Agents that expose no session modes ignore it. This is a cooperative permission scope enforced by the agent, not an OS sandbox." } } } diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index b39664bcc..156b51133 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -69,6 +69,15 @@ pub struct DelegationRequest { /// the defaulted value the child is actually spawned in. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_working_dir: Option, + /// Session mode the child should start in, as the LLM passed it in the + /// `delegate_to_agent` arguments. Overrides the per-agent + /// `AgentDelegationDefaults::mode_id` from settings for THIS call only; + /// `None` keeps the configured default, so omitting it is a no-op. The + /// value is the target agent's own ACP session mode id (the same + /// vocabulary the settings default uses), forwarded verbatim as + /// `ConnectionSpawner::spawn`'s `preferred_mode_id`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_handle: Option, } diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 2579cecdc..958264969 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -2736,6 +2736,7 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + permission_mode: None, external_handle: None, } }