Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion src-tauri/src/acp/delegation/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
// 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.
Expand Down Expand Up @@ -3757,6 +3761,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
permission_mode: None,
external_handle: None,
}
}
Expand Down Expand Up @@ -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<dyn ConnectionSpawner>, 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<dyn ConnectionSpawner>, 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<dyn ConnectionSpawner>, 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
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/acp/delegation/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -1337,6 +1349,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
permission_mode: None,
external_handle: None,
})
.await;
Expand Down Expand Up @@ -1490,6 +1503,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
permission_mode: None,
external_handle: None,
})
.await
Expand Down Expand Up @@ -1592,6 +1606,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
permission_mode: None,
external_handle: None,
})
.await;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/acp/delegation/tool_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/acp/delegation/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_handle: Option<String>,
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/acp/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2736,6 +2736,7 @@ mod tests {
task: "do x".into(),
working_dir: None,
requested_working_dir: None,
permission_mode: None,
external_handle: None,
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/components/message/delegated-sub-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function DelegatedSubThread({
const [dialogOpen, setDialogOpen] = useState(false)
const {
agentType,
permissionMode,
task,
taskId,
status,
Expand Down Expand Up @@ -112,6 +113,14 @@ export function DelegatedSubThread({
#{taskId.slice(0, 8)}
</span>
)}
{permissionMode && (
<span
className="shrink-0 rounded border border-border px-1 py-px font-mono text-[10px] leading-none text-muted-foreground"
title={t("delegationPinnedMode", { mode: permissionMode })}
>
{permissionMode}
</span>
)}
<StatusBadge status={status} errorCode={errorCode} />
</div>
{task && (
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/use-delegation-card-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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é.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "委任されたサブエージェントの会話を読み取り専用で表示します。",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2984,6 +2984,7 @@
"subAgentRunning": "子智能体运行中…",
"noDetail": "暂无详情。",
"unknownAgent": "子智能体",
"delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation",
"openDetail": "查看会话",
"detailTitle": "子智能体会话",
"detailDescription": "只读查看委托给子智能体的会话内容。",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -2984,6 +2984,7 @@
"subAgentRunning": "子代理執行中…",
"noDetail": "暫無詳情。",
"unknownAgent": "子智慧體",
"delegationPinnedMode": "Started in {mode} mode, pinned by the parent for this delegation",
"openDetail": "檢視會話",
"detailTitle": "子智慧體會話",
"detailDescription": "唯讀檢視委派給子智慧體的會話內容。",
Expand Down
25 changes: 25 additions & 0 deletions src/lib/delegation-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading
Loading