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, } } diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 993e090cc..a2408c489 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -62,6 +62,7 @@ export function DelegatedSubThread({ const [dialogOpen, setDialogOpen] = useState(false) const { agentType, + permissionMode, task, taskId, status, @@ -112,6 +113,14 @@ export function DelegatedSubThread({ #{taskId.slice(0, 8)} )} + {permissionMode && ( + + {permissionMode} + + )} {task && ( diff --git a/src/hooks/use-delegation-card-model.ts b/src/hooks/use-delegation-card-model.ts index 0d0b3eda6..4bb397178 100644 --- a/src/hooks/use-delegation-card-model.ts +++ b/src/hooks/use-delegation-card-model.ts @@ -53,6 +53,9 @@ export interface DelegationCardModel { errorCode: string | undefined childConversationId: number | null childConnectionId: string | null + /** Session mode the parent pinned for this delegation, or `null` when it + * used the configured default. */ + permissionMode: string | null /** False when there's no live binding and the input parsed to neither an * agent type nor a task — nothing useful to draw. Callers render null. */ hasModel: boolean @@ -150,6 +153,9 @@ export function useDelegationCardModel( errorCode, childConversationId, childConnectionId, + // Only the parsed arguments carry this; hosts that strip arguments simply + // show no mode, which reads correctly as "the configured default". + permissionMode: parsed.permissionMode, // Broker-stamped meta alone is proof enough of a delegation — the // persisted Cursor shape has empty raw_input and no live binding. hasModel: Boolean(binding || parsed.agentType || parsed.task || parsedMeta), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..8d62261c2 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "العميل الفرعي قيد التشغيل…", "noDetail": "No detail available yet.", "unknownAgent": "وكيل فرعي", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "عرض المحادثة", "detailTitle": "محادثة الوكيل الفرعي", "detailDescription": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..215eb98d6 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Unteragent läuft…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-Agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Konversation anzeigen", "detailTitle": "Unteragent-Konversation", "detailDescription": "Schreibgeschützte Ansicht der delegierten Unteragent-Konversation.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..f9df0711c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Sub-agent running…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Open conversation", "detailTitle": "Sub-agent conversation", "detailDescription": "Read-only view of the delegated sub-agent's conversation.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..db272457d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Subagente en ejecución…", "noDetail": "No detail available yet.", "unknownAgent": "Sub-agente", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Ver conversación", "detailTitle": "Conversación del subagente", "detailDescription": "Vista de solo lectura de la conversación del subagente delegado.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..d71c91239 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Sous-agent en cours…", "noDetail": "Aucun détail disponible pour le moment.", "unknownAgent": "Sous-agent", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Voir la conversation", "detailTitle": "Conversation du sous-agent", "detailDescription": "Vue en lecture seule de la conversation du sous-agent délégué.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..fd3f87373 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "サブエージェント実行中…", "noDetail": "No detail available yet.", "unknownAgent": "サブエージェント", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "会話を表示", "detailTitle": "サブエージェントの会話", "detailDescription": "委任されたサブエージェントの会話を読み取り専用で表示します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..0b8948e59 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "서브에이전트 실행 중…", "noDetail": "No detail available yet.", "unknownAgent": "하위 에이전트", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "대화 보기", "detailTitle": "서브에이전트 대화", "detailDescription": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..f9a74a30b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "Subagente em execução…", "noDetail": "No detail available yet.", "unknownAgent": "Subagente", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "Ver conversa", "detailTitle": "Conversa do subagente", "detailDescription": "Visualização somente leitura da conversa do subagente delegado.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..0d0af9db0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "子智能体运行中…", "noDetail": "暂无详情。", "unknownAgent": "子智能体", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "查看会话", "detailTitle": "子智能体会话", "detailDescription": "只读查看委托给子智能体的会话内容。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..d01904cc0 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2984,6 +2984,7 @@ "subAgentRunning": "子代理執行中…", "noDetail": "暫無詳情。", "unknownAgent": "子智慧體", + "delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation", "openDetail": "檢視會話", "detailTitle": "子智慧體會話", "detailDescription": "唯讀檢視委派給子智慧體的會話內容。", diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 9492a7dfc..1d0bab8ec 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -174,4 +174,29 @@ describe("parseDelegationMeta task fields", () => { expect(parsed?.task).toBeNull() expect(parsed?.taskId).toBeNull() }) + + it("parses permission_mode and trims it", () => { + const parsed = parseInput( + JSON.stringify({ + agent_type: "codex", + task: "t", + permission_mode: " plan ", + }) + ) + expect(parsed.permissionMode).toBe("plan") + }) + + it("treats a blank permission_mode as absent", () => { + const parsed = parseInput( + JSON.stringify({ agent_type: "codex", task: "t", permission_mode: " " }) + ) + expect(parsed.permissionMode).toBeNull() + }) + + it("leaves permissionMode null when the argument is omitted", () => { + const parsed = parseInput( + JSON.stringify({ agent_type: "codex", task: "t" }) + ) + expect(parsed.permissionMode).toBeNull() + }) }) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 9231dd06d..40feab8d3 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -36,6 +36,10 @@ export type ParsedInput = { agentType: AgentType | null task: string | null workingDir: string | null + /** Session mode the parent pinned for this one delegation, from the + * `permission_mode` argument. `null` when omitted, which means the child + * used the configured per-agent default. */ + permissionMode: string | null } // Derived from the canonical `ALL_AGENT_TYPES` so a newly added agent is @@ -116,6 +120,7 @@ const EMPTY_PARSED_INPUT: ParsedInput = { agentType: null, task: null, workingDir: null, + permissionMode: null, } // Wrapper keys that hosts use to nest the actual tool arguments. JSON-RPC @@ -157,7 +162,8 @@ function findDelegationArgs( if ( typeof obj.task === "string" || typeof obj.agent_type === "string" || - typeof obj.working_dir === "string" + typeof obj.working_dir === "string" || + typeof obj.permission_mode === "string" ) { return obj } @@ -240,6 +246,10 @@ export function parseInput(raw: string | null | undefined): ParsedInput { agentType: at && KNOWN_AGENT_TYPES.has(at) ? (at as AgentType) : null, task: typeof obj.task === "string" ? obj.task : null, workingDir: typeof obj.working_dir === "string" ? obj.working_dir : null, + permissionMode: + typeof obj.permission_mode === "string" && obj.permission_mode.trim() + ? obj.permission_mode.trim() + : null, } }