From 7b481237394afcb6fe18562b2bc42efdc2a0d5d4 Mon Sep 17 00:00:00 2001 From: asteroida123 <264808420+asteroida123@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:00:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(delegation):=20@Agent=20=E6=8F=90=E5=8F=8A?= =?UTF-8?q?=E5=8D=95=E6=AC=A1=E5=A7=94=E6=89=98=E9=85=8D=E7=BD=AE=EF=BC=88?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B/=E6=80=9D=E8=80=83/=E6=9D=83=E9=99=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 @Agent 委托链路上增加单次(per-call)配置能力:@ 面板 agent 行尾显示 该 agent 全局委托默认的只读摘要与配置入口,点击入口插入 mention 并打开 懒加载的配置面板;修改只作用于本次委托,优先级为 per-call override > 持久化全局默认 > agent 原生默认。 交互契约: - 普通 @Agent 选择 = 纯插入,不回调 onAgentMention、不探测、不带 override; - 行尾入口(span[role=button],Tab/Enter/Space 可达)携带只读摘要 (模型 · 模式,稳定 ID),点击才插入并打开 AgentDelegationConfigPopover; - 探测(describeAgentOptions)只发生在面板打开时,列表渲染零探测、零阻塞; - 点击已插入的 agent badge 可重开其配置面板(reference 节点 storage 回调); - 面板初始值继承全局默认,修改时发完整有效配置(后端整体替换、不合并), boolean 配置(如 Cline auto_approve)支持 true/false,重置即删除 override; - 同一 agent 多个 mention 共享一份配置(显式限制,写入测试); - 队列/重排队/草稿编辑全程保留 override(hydration 时写入恢复出的 badge,文档为唯一事实来源);popover 每次打开重新拉取全局基线。 后端(one-shot 语义不变,MCP schema 不变): - ConnectionCommand::Prompt 携带 delegation_overrides,connection loop 在 turn 开始前写入 SessionState,TurnComplete 随 turn_in_flight 清除, 断开随状态丢弃;admission gate 保证被拒并发 prompt 无法覆盖活跃值; - ParentSessionLookup.delegation_override 按请求 agent_type 读取当前 turn 的覆盖,DelegationRequest.per_call_defaults 兼容旧 payload (skip_serializing_if);broker 按三级优先级选值,绝不回写全局配置。 探针路径顺带修复:probe 连接不再注入 codeg-mcp 伴生进程——探测只为读 广告选项,注入使探测额外承担一次 MCP 握手(且在 MCP 同步不稳的适配器 上把整个探测拖成失败)。 基于 upstream/main(含 @-mention 路由帧与 resume_delegation)。 --- src-tauri/src/acp/connection.rs | 19 + src-tauri/src/acp/delegation/broker.rs | 243 ++++++++++- src-tauri/src/acp/delegation/listener.rs | 153 ++++++- src-tauri/src/acp/delegation/types.rs | 37 +- src-tauri/src/acp/lifecycle.rs | 1 + src-tauri/src/acp/manager.rs | 193 ++++++++- src-tauri/src/acp/session_state.rs | 57 ++- src-tauri/src/automation/engine.rs | 3 +- src-tauri/src/commands/acp.rs | 10 + src-tauri/src/commands/delegation.rs | 41 +- src-tauri/src/web/handlers/acp.rs | 6 + src-tauri/src/work_task/engine.rs | 6 +- src-tauri/tests/delegation_e2e_uds.rs | 8 + src-tauri/tests/delegation_e2e_windows.rs | 8 + src/components/chat/chat-input.tsx | 6 + .../agent-delegation-config-popover.test.tsx | 320 +++++++++++++++ .../agent-delegation-config-popover.tsx | 254 ++++++++++++ .../chat/composer/nodes/reference-node.ts | 26 ++ .../chat/composer/nodes/reference-view.tsx | 26 +- .../composer/rich-composer-mention.test.tsx | 131 ++++++ .../chat/composer/rich-composer.tsx | 54 ++- .../suggestion/suggestion-popup.test.tsx | 92 ++++- .../composer/suggestion/suggestion-popup.tsx | 73 +++- src/components/chat/composer/types.ts | 11 +- src/components/chat/conversation-shell.tsx | 6 + src/components/chat/message-input.tsx | 225 +++++++++- .../chat/session-config-selector.tsx | 17 +- .../conversation-detail-panel.tsx | 12 + src/contexts/acp-connections-context.tsx | 10 +- src/hooks/use-connection-lifecycle.ts | 12 +- src/hooks/use-connection.ts | 5 + src/hooks/use-delegation-baseline.test.ts | 149 +++++++ src/hooks/use-delegation-baseline.ts | 107 +++++ src/hooks/use-message-queue.test.ts | 56 +++ src/i18n/messages/ar.json | 8 +- src/i18n/messages/de.json | 8 +- src/i18n/messages/en.json | 8 +- src/i18n/messages/es.json | 8 +- src/i18n/messages/fr.json | 8 +- src/i18n/messages/ja.json | 8 +- src/i18n/messages/ko.json | 8 +- src/i18n/messages/pt.json | 8 +- src/i18n/messages/zh-CN.json | 8 +- src/i18n/messages/zh-TW.json | 8 +- src/lib/api-delegation-overrides.test.ts | 71 ++++ src/lib/api.ts | 8 +- src/lib/delegation-overrides.test.ts | 387 ++++++++++++++++++ src/lib/delegation-overrides.ts | 254 ++++++++++++ src/lib/tauri.ts | 12 +- src/lib/types.ts | 10 + 50 files changed, 3148 insertions(+), 51 deletions(-) create mode 100644 src/components/chat/composer/agent-delegation-config-popover.test.tsx create mode 100644 src/components/chat/composer/agent-delegation-config-popover.tsx create mode 100644 src/hooks/use-delegation-baseline.test.ts create mode 100644 src/hooks/use-delegation-baseline.ts create mode 100644 src/lib/api-delegation-overrides.test.ts create mode 100644 src/lib/delegation-overrides.test.ts create mode 100644 src/lib/delegation-overrides.ts diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 581e190642..07ce81748a 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -52,6 +52,7 @@ use crate::acp::types::{ UserMessageBlock, }; use crate::logging::throttle::LeadingEdgeThrottle; +use crate::acp::delegation::types::AgentDelegationDefaults; use crate::models::agent::AgentType; use crate::network::proxy; use crate::web::event_bridge::{emit_with_state, EventEmitter}; @@ -934,6 +935,12 @@ pub enum ConnectionCommand { /// prompt actually being processed. `None` for delegation children, /// empty prompts, unbound conversations, and non-linked senders. user_message: Option<(String, Vec)>, + /// Draft-scoped delegation overrides (`@Agent` mention config) for the + /// turn this prompt starts. The loop installs the map into the session + /// state when the command is dequeued (before the agent request goes + /// out) and `TurnComplete` clears it. An empty map = no per-call + /// override — delegations then use the persisted global defaults. + delegation_overrides: BTreeMap, }, SetMode { mode_id: String, @@ -8109,7 +8116,19 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::Prompt { blocks, user_message, + delegation_overrides, }) => { + // Install the turn's per-mention delegation overrides BEFORE + // any event of this turn fires, so a delegation the LLM issues + // mid-turn reads exactly this prompt's config. The admission + // gate (turn_in_flight, set by the manager before enqueue) + // already guarantees no concurrent prompt can clobber these + // values mid-turn; `TurnComplete` clears them with the gate. + state + .write() + .await + .delegation_overrides + .clone_from(&delegation_overrides); // Fingerprint the outgoing prompt for the background watcher's // foreground/out-of-turn classifier BEFORE the blocks are // consumed: the transcript record this prompt becomes must diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 616a5784b2..5338bc6bb6 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -2460,14 +2460,35 @@ impl DelegationBroker { } // --- Spawn child connection -------------------------------------------- - // 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 - .agent_defaults - .get(&req.agent_type) - .map(|d: &AgentDelegationDefaults| (d.mode_id.clone(), d.config_values.clone())) - .unwrap_or((None, BTreeMap::new())); + // Per-call config wins over the persisted global default, which wins + // over the agent's native default (empty preferred values). The + // per-call value comes from the parent turn's `@Agent` mention + // popover; it is EPHEMERAL (turn-scoped on the parent connection) and + // never merges into `DelegationConfig::agent_defaults`. A non-empty + // per-call value REPLACES the global default wholesale (no merge) — + // the composer emits the full effective selection so an untouched row + // keeps its global pin, and a partial override could otherwise + // silently drop it. Cloning is cheap — `AgentDelegationDefaults` is at + // most one Option and a small BTreeMap, and the spawner + // consumes both fields by value. + let per_call = req + .per_call_defaults + .take() + .map(|mut defaults| { + defaults.normalize(); + defaults + }) + .filter(|d| !d.is_empty()); + let (preferred_mode_id, preferred_config_values) = if let Some(d) = per_call { + (d.mode_id, d.config_values) + } else { + cfg.agent_defaults + .get(&req.agent_type) + .map(|d: &AgentDelegationDefaults| { + (d.mode_id.clone(), d.config_values.clone()) + }) + .unwrap_or((None, BTreeMap::new())) + }; // 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. @@ -4621,6 +4642,7 @@ mod tests { use crate::acp::delegation::spawner::{mock::MockSpawner, ResumedSpawn, SpawnerError}; use crate::acp::delegation::types::DelegationSuccess; use crate::models::AgentType; + use serde_json::json; /// Test-only `ConversationDepthLookup` that resolves against a flat /// (id, parent_id) table. Unknown ids return `Ok(None)` to keep test @@ -4649,6 +4671,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: None, + per_call_defaults: None, } } @@ -5408,6 +5431,210 @@ mod tests { assert!(args[0].preferred_config_values.is_empty()); } + #[tokio::test] + async fn per_call_defaults_win_over_global_agent_defaults() { + // The parent turn's @Agent override REPLACES the persisted global + // default wholesale (no merge) — the composer emits the full effective + // selection precisely so an untouched row keeps its global pin. + 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 global_cfg = BTreeMap::new(); + global_cfg.insert("model".into(), "global-model".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("global-mode".into()), + config_values: global_cfg, + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.agent_type = AgentType::Codex; + let mut per_call_cfg = BTreeMap::new(); + per_call_cfg.insert("model".into(), "per-call-model".into()); + req.per_call_defaults = Some(AgentDelegationDefaults { + mode_id: Some("per-call-mode".into()), + config_values: per_call_cfg, + }); + 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("per-call-mode")); + assert_eq!( + args[0].preferred_config_values.get("model").map(String::as_str), + Some("per-call-model") + ); + } + + #[tokio::test] + async fn empty_per_call_defaults_fall_back_to_global_agent_defaults() { + // A per-call value the composer normalized away (empty = "use global") + // must not shadow the persisted 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 global_cfg = BTreeMap::new(); + global_cfg.insert("model".into(), "global-model".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("global-mode".into()), + config_values: global_cfg, + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.agent_type = AgentType::Codex; + req.per_call_defaults = Some(AgentDelegationDefaults::default()); + 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("global-mode")); + assert_eq!( + args[0].preferred_config_values.get("model").map(String::as_str), + Some("global-model") + ); + } + + #[tokio::test] + async fn malformed_empty_per_call_defaults_fall_back_to_global_agent_defaults() { + 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 global_cfg = BTreeMap::new(); + global_cfg.insert("model".into(), "global-model".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("global-mode".into()), + config_values: global_cfg, + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.agent_type = AgentType::Codex; + let mut malformed_cfg = BTreeMap::new(); + malformed_cfg.insert("model".into(), " ".into()); + req.per_call_defaults = Some(AgentDelegationDefaults { + mode_id: Some("".into()), + config_values: malformed_cfg, + }); + 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("global-mode")); + assert_eq!( + args[0].preferred_config_values.get("model").map(String::as_str), + Some("global-model") + ); + } + + #[tokio::test] + async fn per_call_defaults_only_apply_to_the_requested_agent() { + // A per-call override captured for Codex must not leak into a + // delegation targeting a different agent. + // The send step fails on purpose (the test-only `handle_request` + // blocks until the task is terminal; a successful send would park it). + 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; + mock.queue_spawn(Ok("child-2".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + + let mut req = request(1, "pt-1"); + req.agent_type = AgentType::Codex; + req.per_call_defaults = Some(AgentDelegationDefaults { + mode_id: Some("per-call-mode".into()), + config_values: BTreeMap::new(), + }); + let _ = broker.handle_request(req).await; + + // Second call, no override, different agent: global defaults are empty + // for ClaudeCode, so the spawner must see none of Codex's values. + let _ = broker.handle_request(request(1, "pt-2")).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 2); + assert_eq!(args[1].agent_type, AgentType::ClaudeCode); + assert!(args[1].preferred_mode_id.is_none()); + assert!(args[1].preferred_config_values.is_empty()); + } + + #[tokio::test] + async fn request_without_per_call_field_stays_backward_compatible() { + // An old payload (no `per_call_defaults` key at all) deserializes with + // `None` and behaves exactly like today: global defaults or none. + let raw = json!({ + "parent_connection_id": "parent-conn", + "parent_conversation_id": 1, + "parent_tool_use_id": "pt-1", + "agent_type": "codex", + "task": "do x", + }); + let req: DelegationRequest = serde_json::from_value(raw).expect("legacy payload parses"); + assert!(req.per_call_defaults.is_none()); + + // And a wire form carrying an override round-trips it. + let raw = json!({ + "parent_connection_id": "parent-conn", + "parent_conversation_id": 1, + "parent_tool_use_id": "pt-1", + "agent_type": "codex", + "task": "do x", + "per_call_defaults": {"mode_id": "plan"}, + }); + let req: DelegationRequest = serde_json::from_value(raw).expect("override payload parses"); + assert_eq!(req.per_call_defaults.unwrap().mode_id.as_deref(), Some("plan")); + } + #[tokio::test] async fn send_failure_after_spawn_disconnects_child() { let mock = Arc::new(MockSpawner::new()); diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 9df5536195..120a7c4abb 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -24,7 +24,8 @@ use crate::acp::delegation::transport::{ BrokerTaskCompleteRequest, BrokerTaskProgressRequest, }; use crate::acp::delegation::types::{ - DelegationRequest, DelegationTaskReport, ResumeDelegationRequest, TaskStatus, + AgentDelegationDefaults, DelegationRequest, DelegationTaskReport, ResumeDelegationRequest, + TaskStatus, }; use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess}; use crate::acp::question::{QuestionOutcome, SessionQuestionAccess}; @@ -50,6 +51,16 @@ const STATUS_WAIT_MAX_MS: u64 = 60_000; #[async_trait] pub trait ParentSessionLookup: Send + Sync { async fn current_conversation_id(&self, parent_connection_id: &str) -> Option; + /// The CURRENT turn's draft-scoped delegation override for `agent_type` on + /// this parent connection (captured from the `@Agent` mention that asked + /// for the delegation). `None` = the turn carries no per-call override and + /// the broker falls back to the persisted global defaults. Backend state + /// only — never inferred from the task text. + async fn delegation_override( + &self, + parent_connection_id: &str, + agent_type: AgentType, + ) -> Option; } /// Per-launch token entry. Bound at MCP injection time and revoked on parent @@ -685,6 +696,14 @@ impl DelegationListener { .clone() .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + // Per-call config override from the parent's CURRENT turn (the + // `@Agent` mention's popover). Read from the parent connection's turn + // state, scoped to the requested agent; the parent's prompt admission + // gate guarantees the values belong to the turn that is running. + let per_call_defaults = self + .parent_lookup + .delegation_override(&req.parent_connection_id, agent_type) + .await; let delegation_req = DelegationRequest { parent_connection_id: req.parent_connection_id, parent_conversation_id, @@ -694,6 +713,7 @@ impl DelegationListener { working_dir, requested_working_dir, external_handle: req.external_handle, + per_call_defaults, }; self.broker.start_delegation(delegation_req).await } @@ -903,6 +923,14 @@ mod tests { async fn current_conversation_id(&self, _parent_connection_id: &str) -> Option { self.0 } + + async fn delegation_override( + &self, + _parent_connection_id: &str, + _agent_type: AgentType, + ) -> Option { + None + } } /// In-memory feedback stub. `read_pending_feedback` returns the seeded notes @@ -1203,6 +1231,125 @@ mod tests { } } + /// Parent lookup carrying per-call overrides keyed by agent — lets a test + /// prove the listener reads the override for the REQUESTED agent only. + struct OverrideParentLookup { + conversation: Option, + overrides: std::collections::BTreeMap, + } + #[async_trait] + impl ParentSessionLookup for OverrideParentLookup { + async fn current_conversation_id(&self, _parent_connection_id: &str) -> Option { + self.conversation + } + + async fn delegation_override( + &self, + _parent_connection_id: &str, + agent_type: AgentType, + ) -> Option { + self.overrides.get(&agent_type).cloned() + } + } + + #[tokio::test] + async fn per_call_override_forwarded_for_the_requested_agent_only() { + let mut overrides = std::collections::BTreeMap::new(); + overrides.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("plan".into()), + config_values: std::iter::once(("model".to_string(), "gpt-5.2-codex".to_string())) + .collect(), + }, + ); + + let mock = Arc::new(MockSpawner::new()); + let broker = make_broker(mock.clone()).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = DelegationListener::new( + broker.clone(), + tokens.clone(), + Arc::new(OverrideParentLookup { + conversation: Some(1), + overrides, + }), + Arc::new(StubFeedback::default()), + Arc::new(StubQuestion::default()), + Arc::new(StubSessionInfo::default()), + Arc::new(StubTaskTools), + Arc::new(StubAuthoring::default()), + ); + + // Codex delegation: the override rides into the spawner. + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Ok(42)).await; + let report = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + assert_eq!(report.status, TaskStatus::Running); + + // ClaudeCode delegation: no per-call entry, and the broker config + // carries no global default either — so nothing is forwarded. + mock.queue_spawn(Ok("child-2".into())).await; + mock.queue_send(Ok(43)).await; + let report = listener + .process(make_request(json!({"agent_type": "claude_code", "task": "y"})).await) + .await; + assert_eq!(report.status, TaskStatus::Running); + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 2); + assert_eq!(args[0].agent_type, AgentType::Codex); + assert_eq!(args[0].preferred_mode_id.as_deref(), Some("plan")); + assert_eq!( + args[0].preferred_config_values.get("model").map(String::as_str), + Some("gpt-5.2-codex") + ); + assert_eq!(args[1].agent_type, AgentType::ClaudeCode); + assert!(args[1].preferred_mode_id.is_none()); + assert!(args[1].preferred_config_values.is_empty()); + } + + #[tokio::test] + async fn per_call_override_absent_when_lookup_has_none() { + let mock = Arc::new(MockSpawner::new()); + let broker = make_broker(mock.clone()).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker.clone(), tokens, Some(1)); + + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Ok(42)).await; + let report = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + assert_eq!(report.status, TaskStatus::Running); + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + // Old behavior preserved: no per-call state → (None, empty). + assert!(args[0].preferred_mode_id.is_none()); + assert!(args[0].preferred_config_values.is_empty()); + } + #[tokio::test] async fn invalid_token_rejected() { let listener = make_listener( @@ -1394,6 +1541,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: None, + per_call_defaults: None, }) .await; let task_id = ack.task_id.clone().expect("running task carries an id"); @@ -1547,6 +1695,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: None, + per_call_defaults: None, }) .await .task_id @@ -1649,6 +1798,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: None, + per_call_defaults: None, }) .await; let task_id = ack.task_id.clone().unwrap(); @@ -1700,6 +1850,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: Some("h-1".into()), + per_call_defaults: None, }; broker.handle_request(req).await }) diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index 43f9e24f5c..377479537b 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -33,8 +33,34 @@ pub struct AgentDelegationDefaults { } impl AgentDelegationDefaults { + /// Remove malformed empty ids and values at the backend boundary. The + /// frontend normalizes these already, but delegation requests also arrive + /// through MCP and must not turn an empty object into a wholesale + /// replacement of persisted defaults. + pub fn normalize(&mut self) { + if self + .mode_id + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(false) + { + self.mode_id = None; + } + self.config_values + .retain(|key, value| !key.trim().is_empty() && !value.trim().is_empty()); + } + pub fn is_empty(&self) -> bool { - self.mode_id.is_none() && self.config_values.is_empty() + let mode_empty = self + .mode_id + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true); + let config_empty = self + .config_values + .iter() + .all(|(key, value)| key.trim().is_empty() || value.trim().is_empty()); + mode_empty && config_empty } } @@ -71,6 +97,15 @@ pub struct DelegationRequest { pub requested_working_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_handle: Option, + /// Draft-scoped config override captured from the parent's `@Agent` + /// mention (per-mention popover), carried per call so the broker can + /// prefer it over the persisted `agent_defaults`. Backend-internal — the + /// listener fills it from the parent connection's turn state, never from + /// the task text. `None` (or an empty value) = use the global default. + /// Wire-tolerant: absent on every payload produced before this field + /// existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub per_call_defaults: Option, } /// Everything the broker needs to resume one interrupted delegation task. diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index f574d491db..1ea938c52b 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -2901,6 +2901,7 @@ mod tests { working_dir: None, requested_working_dir: None, external_handle: None, + per_call_defaults: None, } } diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 21dbc3b457..cf6a1e0e0e 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -1,4 +1,6 @@ use std::collections::BTreeMap; + +use crate::acp::delegation::types::AgentDelegationDefaults; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -446,6 +448,40 @@ impl ConnectionManager { emitter: EventEmitter, preferred_mode_id: Option, preferred_config_values: BTreeMap, + ) -> Result { + self.spawn_agent_impl( + agent_type, + working_dir, + session_id, + runtime_env, + owner_window_label, + emitter, + preferred_mode_id, + preferred_config_values, + true, + ) + .await + } + + /// As [`spawn_agent`], with control over the `codeg-mcp` companion + /// injection. Probe connections pass `false`: they exist only to read the + /// agent's advertised options, and injecting the companion makes every + /// probe pay for an MCP handshake the probe never uses — and, on adapters + /// whose MCP tool synchronization is flaky, turns the whole probe into a + /// failure (surfaced in the UI as "initial connection or tool + /// synchronization failed"). + #[allow(clippy::too_many_arguments)] + async fn spawn_agent_impl( + &self, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + runtime_env: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + include_companion: bool, ) -> Result { // Connection dedup: when resuming an agent session (session_id is // Some), look for a live AgentConnection that already represents @@ -516,7 +552,11 @@ impl ConnectionManager { self.connections.clone(), preferred_mode_id, preferred_config_values, - self.delegation_snapshot(), + if include_companion { + self.delegation_snapshot() + } else { + None + }, self.terminal_shell_config.clone(), ) .await?; @@ -730,6 +770,7 @@ impl ConnectionManager { conn_id: &str, mut blocks: Vec, user_message: Option<(String, Vec)>, + delegation_overrides: BTreeMap, ) -> Result<(), AcpError> { // Reject an empty prompt BEFORE touching the concurrency gate. An empty // prompt produces no turn — and thus no `TurnComplete` to clear the gate @@ -790,6 +831,7 @@ impl ConnectionManager { permit.send(ConnectionCommand::Prompt { blocks, user_message, + delegation_overrides, }); Ok(()) } @@ -813,9 +855,10 @@ impl ConnectionManager { conn_id: &str, blocks: Vec, ) -> Result<(), AcpError> { - let prompt_lock = self.clone_prompt_lock(conn_id).await?; - let _guard = prompt_lock.lock_owned().await; - self.send_prompt_inner(conn_id, blocks, None).await + // Internal/non-UI callers never carry per-mention overrides; the + // chat-side path goes through `send_prompt_linked_with_message_id`. + self.send_prompt_inner(conn_id, blocks, None, BTreeMap::new()) + .await } /// Send a prompt while ensuring a `Conversation` DB row is bound to this @@ -845,6 +888,8 @@ impl ConnectionManager { conversation_id: Option, delegation: Option, ) -> Result, AcpError> { + // Back-compat wrapper for internal callers (delegation children, + // tests): no per-mention overrides, no client message id. self.send_prompt_linked_with_message_id( db, conn_id, @@ -853,6 +898,7 @@ impl ConnectionManager { conversation_id, delegation, None, + BTreeMap::new(), ) .await } @@ -875,6 +921,7 @@ impl ConnectionManager { conversation_id: Option, delegation: Option, client_message_id: Option, + delegation_overrides: BTreeMap, ) -> Result, AcpError> { // Reject an empty prompt up front, BEFORE any side effects: linking / // creating the conversation row, flipping it to InProgress, or emitting @@ -1322,7 +1369,8 @@ impl ConnectionManager { // lifecycle subscriber's PendingReview write also never fires and the // row would be stuck until a follow-up `send_prompt_linked` re-flipped it. match self - .send_prompt_inner(conn_id, blocks, user_message) + .send_prompt_inner(conn_id, blocks, user_message, delegation_overrides) + .await { Ok(()) => { @@ -2063,7 +2111,7 @@ impl ConnectionManager { }; let _probe_guard = per_agent_lock.lock_owned().await; let conn_id = self - .spawn_agent( + .spawn_agent_impl( agent_type, working_dir, None, // brand-new session — no resume @@ -2072,6 +2120,10 @@ impl ConnectionManager { EventEmitter::Noop, None, BTreeMap::new(), + // Probes never prompt, delegate, or answer questions: skip the + // companion so the probe can't fail on (or wait for) an MCP + // handshake it has no use for. + false, ) .await?; @@ -2393,6 +2445,21 @@ impl ConnectionManager { connections.get(conn_id).map(|conn| conn.state.clone()) } + /// Read the CURRENT turn's per-mention delegation overrides for one agent + /// on this connection. `None` while no turn is in flight (the map is + /// installed when the loop dequeues the prompt and cleared with the turn), + /// or when the connection is gone. Used by `ConnectionManagerParentLookup` + /// to serve the delegation listener. + async fn delegation_override_for( + &self, + conn_id: &str, + agent_type: AgentType, + ) -> Option { + let state = self.get_state(conn_id).await?; + let snapshot = state.read().await; + snapshot.delegation_overrides.get(&agent_type).cloned() + } + /// Like `get_state`, but also clones the connection's `EventEmitter`. /// Used by the lifecycle subscriber when it needs to both update the /// per-session state and re-broadcast a derived event (e.g. emitting @@ -3447,6 +3514,16 @@ impl crate::acp::delegation::listener::ParentSessionLookup for ConnectionManager let snapshot = state.read().await; snapshot.conversation_id } + + async fn delegation_override( + &self, + parent_connection_id: &str, + agent_type: AgentType, + ) -> Option { + self.manager + .delegation_override_for(parent_connection_id, agent_type) + .await + } } /// Production impl of `SessionFeedbackAccess` for the delegation listener's @@ -4176,6 +4253,7 @@ mod tests { None, None, Some("optimistic-route".into()), + BTreeMap::new(), ) .await .unwrap(); @@ -4184,6 +4262,7 @@ mod tests { let ConnectionCommand::Prompt { blocks, user_message, + .. } = command else { panic!("expected prompt command"); @@ -4243,6 +4322,7 @@ mod tests { None, None, Some("optimistic-reserved".into()), + BTreeMap::new(), ) .await .expect("an invisible control character must not block the send"); @@ -4913,6 +4993,7 @@ mod tests { text: "filler".into(), }], user_message: None, + delegation_overrides: BTreeMap::new(), }) .await .unwrap(); @@ -4925,6 +5006,7 @@ mod tests { text: "blocked".into(), }], None, + BTreeMap::new(), ); let res = tokio::time::timeout(std::time::Duration::from_millis(50), fut).await; assert!( @@ -4968,6 +5050,7 @@ mod tests { None, None, Some("optimistic-abc".to_string()), + BTreeMap::new(), ) .await .expect("send"); @@ -4983,6 +5066,104 @@ mod tests { ); } + #[tokio::test] + async fn prompt_with_overrides_carries_the_map_on_the_command() { + // The manager forwards the per-mention delegation overrides onto the + // Prompt command; the connection loop installs them into the session + // state when the command is dequeued (before the turn starts). + use crate::acp::connection::ConnectionCommand; + use crate::db::test_helpers; + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/um-ovr").await; + let mgr = ConnectionManager::new(); + let conn_id = "conn-um-ovr"; + let mut cmd_rx = insert_live_connection( + &mgr, + conn_id, + AgentType::ClaudeCode, + Some(PathBuf::from("/tmp/um-ovr")), + ) + .await; + + let mut overrides = BTreeMap::new(); + overrides.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("plan".into()), + config_values: std::iter::once(("model".to_string(), "gpt-5.2".to_string())) + .collect(), + }, + ); + mgr.send_prompt_linked_with_message_id( + &db, + conn_id, + vec![PromptInputBlock::Text { text: "hi".into() }], + Some(folder_id), + None, + None, + None, + overrides.clone(), + ) + .await + .expect("send"); + + let cmd = cmd_rx.try_recv().expect("prompt enqueued"); + let delegation_overrides = match cmd { + ConnectionCommand::Prompt { + delegation_overrides, + .. + } => delegation_overrides, + _ => panic!("expected a Prompt command on the channel"), + }; + assert_eq!(delegation_overrides, overrides); + } + + #[tokio::test] + async fn delegation_override_for_reads_the_turn_scoped_map() { + // Mirrors the connection loop's install: once the map is in the state, + // `delegation_override_for` serves the requested agent's entry (and + // only that agent's). + use crate::acp::delegation::types::AgentDelegationDefaults; + let mgr = ConnectionManager::new(); + let conn_id = "conn-ovr-lookup"; + let _cmd_rx = insert_live_connection( + &mgr, + conn_id, + AgentType::ClaudeCode, + Some(PathBuf::from("/tmp/ovr-lookup")), + ) + .await; + + // No turn → nothing to serve. + assert!(mgr + .delegation_override_for(conn_id, AgentType::Codex) + .await + .is_none()); + + let mut overrides = BTreeMap::new(); + overrides.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some("plan".into()), + config_values: BTreeMap::new(), + }, + ); + { + let state = mgr.get_state(conn_id).await.unwrap(); + state.write().await.delegation_overrides = overrides; + } + let hit = mgr + .delegation_override_for(conn_id, AgentType::Codex) + .await + .expect("codex override present"); + assert_eq!(hit.mode_id.as_deref(), Some("plan")); + // A different agent's request must not see Codex's entry. + assert!(mgr + .delegation_override_for(conn_id, AgentType::ClaudeCode) + .await + .is_none()); + } + #[tokio::test] async fn send_prompt_linked_failed_reserve_leaves_gate_clear() { // A failed enqueue (dropped cmd receiver) fails at the channel diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 7e9b0da602..5da9fc9f72 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::acp::delegation::types::{BlockedKind, BlockedOn}; +use crate::acp::delegation::types::{AgentDelegationDefaults, BlockedKind, BlockedOn}; use crate::acp::event_stream::{ConnectionEventStream, RecentEventsBuffer}; use crate::acp::feedback::{FeedbackItem, FeedbackStatus}; use crate::acp::plan_approval::PendingPlanApprovalState; @@ -511,6 +511,18 @@ pub struct SessionState { /// not part of the client-visible snapshot. pub turn_in_flight: bool, + /// Draft-scoped delegation config overrides for the CURRENT turn, captured + /// from the parent prompt's `@Agent` mentions (per-mention popover). The + /// connection loop stores the map when it dequeues a `Prompt` command and + /// `AcpEvent::TurnComplete` clears it (mirroring `turn_in_flight`, whose + /// admission gate already guarantees a rejected concurrent prompt never + /// reaches the loop and therefore cannot clobber the active turn's + /// values). The delegation listener reads the requested agent's entry via + /// `ConnectionManagerParentLookup` while the turn runs. Empty = no + /// per-call override. Backend-internal: not part of the client snapshot, + /// and never written to the persisted `delegation.agent_defaults`. + pub delegation_overrides: BTreeMap, + /// Whether the most recently completed turn ended via a stop reason other /// than `"end_turn"` (cancelled, refusal, max_tokens, max_turn_requests, /// empty, unknown — the same "abnormal ending" bucket `connection.rs` @@ -602,6 +614,7 @@ impl SessionState { pending_user_message: None, pending_user_message_started_at: None, turn_in_flight: false, + delegation_overrides: BTreeMap::new(), last_turn_ended_abnormally: false, config_stale: false, config_stale_kind: None, @@ -1009,6 +1022,10 @@ impl SessionState { // cancel, stop-reason — emit TurnComplete; disconnect/error // discard the state entirely, so no stale flag can outlive them.) self.turn_in_flight = false; + // The turn's per-mention delegation overrides die with it: a + // later delegation call (even from a stale MCP companion that + // missed the turn end) falls back to the global defaults. + self.delegation_overrides.clear(); // NOTE: `active_delegations` is intentionally NOT cleared here. // A running delegation's child runs in the background long after // the parent's `delegate_to_agent` tool call returns and this @@ -1883,6 +1900,44 @@ mod tests { /// another row, a cache carried over from the old one would classify the /// new row's first title as a repeat and leave it Untitled for the rest of /// the connection, with no error anywhere to point at. + #[test] + fn turn_complete_clears_delegation_overrides_but_user_message_does_not() { + let mut s = fresh_state(); + s.turn_in_flight = true; + s.delegation_overrides.insert( + AgentType::Codex, + crate::acp::delegation::types::AgentDelegationDefaults { + mode_id: Some("plan".into()), + config_values: std::iter::once(("model".to_string(), "gpt-5.2".to_string())) + .collect(), + }, + ); + + // A mid-turn event (the user-message broadcast) must NOT clear the + // turn's per-mention overrides — the LLM may still delegate later in + // the same turn. + s.apply_event(&AcpEvent::UserMessage { + message_id: "m1".into(), + blocks: Vec::new(), + }); + assert_eq!(s.delegation_overrides.len(), 1); + + // The turn's terminal event clears them, mirroring turn_in_flight. + s.apply_event(&AcpEvent::TurnComplete { + session_id: "sid".into(), + stop_reason: "end_turn".into(), + agent_type: "codex".into(), + }); + assert!(s.delegation_overrides.is_empty()); + assert!(!s.turn_in_flight); + } + + #[test] + fn delegation_overrides_start_empty_on_every_new_session_state() { + let s = fresh_state(); + assert!(s.delegation_overrides.is_empty()); + } + #[test] fn conversation_linked_clears_the_native_title_skip_cache() { let mut s = fresh_state(); diff --git a/src-tauri/src/automation/engine.rs b/src-tauri/src/automation/engine.rs index f8fce73b2c..6e89d3ceed 100644 --- a/src-tauri/src/automation/engine.rs +++ b/src-tauri/src/automation/engine.rs @@ -567,14 +567,13 @@ impl AutomationEngine { match self .manager - .send_prompt_linked_with_message_id( + .send_prompt_linked( &self.db, &conn_id, blocks, Some(cwd.folder_id), Some(conversation_id), None, - None, ) .await { diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e551d751b5..a87b828cc3 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -10007,12 +10007,21 @@ pub async fn acp_connect( #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] +#[allow(clippy::too_many_arguments)] pub async fn acp_prompt( connection_id: String, blocks: Vec, folder_id: Option, conversation_id: Option, client_message_id: Option, + // Draft-scoped delegation overrides for `@Agent` mentions, sent by the + // composer for THIS turn only. Optional: older callers (and an untouched + // mention) omit the field — serde deserializes a missing `Option` field + // as `None`, so behavior is exactly as before. + delegation_overrides: Option< + BTreeMap, + >, db: State<'_, crate::db::AppDatabase>, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { @@ -10025,6 +10034,7 @@ pub async fn acp_prompt( conversation_id, None, client_message_id, + delegation_overrides.unwrap_or_default(), ) .await .map(|_| ()) diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs index 9c2e406e37..f39fe3a186 100644 --- a/src-tauri/src/commands/delegation.rs +++ b/src-tauri/src/commands/delegation.rs @@ -92,7 +92,14 @@ impl DelegationSettings { agent_defaults: self .agent_defaults .into_iter() - .filter(|(_, v)| !v.is_empty()) + .filter_map(|(agent, mut defaults)| { + defaults.normalize(); + if defaults.is_empty() { + None + } else { + Some((agent, defaults)) + } + }) .collect(), // No upper clamp: the cache budget is a user memory choice, not a // safety rail. `0` stays `0` (unlimited). @@ -359,6 +366,38 @@ mod tests { assert!(s.agent_defaults.contains_key(&AgentType::Codex)); } + #[test] + fn clamped_normalizes_empty_agent_default_values() { + let mut malformed = BTreeMap::new(); + malformed.insert("model".into(), " ".into()); + malformed.insert("permission_mode".into(), "plan".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::Codex, + AgentDelegationDefaults { + mode_id: Some(" ".into()), + config_values: malformed, + }, + ); + + let s = DelegationSettings { + agent_defaults, + ..DelegationSettings::default() + } + .clamped(); + + let defaults = s.agent_defaults.get(&AgentType::Codex).unwrap(); + assert!(defaults.mode_id.is_none()); + assert_eq!( + defaults + .config_values + .get("permission_mode") + .map(String::as_str), + Some("plan") + ); + assert!(!defaults.config_values.contains_key("model")); + } + #[tokio::test] async fn set_clamps_out_of_range_values() { let db = crate::db::test_helpers::fresh_in_memory_db().await; diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 753db2e908..7024d7efcd 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -156,6 +156,11 @@ pub struct AcpPromptParams { pub conversation_id: Option, #[serde(default)] pub client_message_id: Option, + /// Draft-scoped delegation overrides for `@Agent` mentions (per-turn + /// only). Optional + default: an untouched mention omits the field. + #[serde(default)] + pub delegation_overrides: + Option>, } pub async fn acp_prompt( @@ -172,6 +177,7 @@ pub async fn acp_prompt( params.conversation_id, None, params.client_message_id, + params.delegation_overrides.unwrap_or_default(), ) .await .map_err(|e| { diff --git a/src-tauri/src/work_task/engine.rs b/src-tauri/src/work_task/engine.rs index b2c6bfa716..d399275de4 100644 --- a/src-tauri/src/work_task/engine.rs +++ b/src-tauri/src/work_task/engine.rs @@ -1400,14 +1400,13 @@ impl TaskEngine { let prompt_head = prompt_head(&blocks); match self .manager - .send_prompt_linked_with_message_id( + .send_prompt_linked( &self.db, &conn_id, blocks, Some(wt.folder_id), Some(conversation_id), None, - None, ) .await { @@ -2038,7 +2037,7 @@ impl TaskEngine { let mut rx = self.bus.subscribe(); let sent = self .manager - .send_prompt_linked_with_message_id( + .send_prompt_linked( &self.db, conn_id, vec![PromptInputBlock::Text { @@ -2047,7 +2046,6 @@ impl TaskEngine { Some(folder_id), Some(conversation_id), None, - None, ) .await; if let Err(e) = sent { diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index bd6c91b89e..64928c4cf4 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -49,6 +49,14 @@ impl ParentSessionLookup for FixedParent { async fn current_conversation_id(&self, _: &str) -> Option { Some(self.0) } + + async fn delegation_override( + &self, + _: &str, + _: codeg_lib::models::AgentType, + ) -> Option { + None + } } /// No-op feedback access — this e2e suite exercises delegation, not feedback. diff --git a/src-tauri/tests/delegation_e2e_windows.rs b/src-tauri/tests/delegation_e2e_windows.rs index e3753f69c3..feab4d160d 100644 --- a/src-tauri/tests/delegation_e2e_windows.rs +++ b/src-tauri/tests/delegation_e2e_windows.rs @@ -40,6 +40,14 @@ impl ParentSessionLookup for FixedParent { async fn current_conversation_id(&self, _: &str) -> Option { Some(self.0) } + + async fn delegation_override( + &self, + _: &str, + _: codeg_lib::models::AgentType, + ) -> Option { + None + } } /// No-op feedback access — this e2e suite exercises delegation, not feedback. diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 3403ec48a3..40a018cb20 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -4,6 +4,7 @@ import type { ConversationFolderPickerOverride } from "@/components/chat/convers import { memo } from "react" import { useTranslations } from "next-intl" import type { + AgentDelegationDefaults, AgentType, ConnectionStatus, PromptCapabilitiesInfo, @@ -55,6 +56,9 @@ interface ChatInputProps { editingItemId?: string | null editingDraftText?: string | null editingDraftBlocks?: PromptInputBlock[] | null + editingDelegationOverrides?: Partial< + Record + > | null isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void @@ -117,6 +121,7 @@ export const ChatInput = memo(function ChatInput({ editingItemId, editingDraftText, editingDraftBlocks, + editingDelegationOverrides, isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, @@ -213,6 +218,7 @@ export const ChatInput = memo(function ChatInput({ editingItemId={editingItemId} editingDraftText={editingDraftText} editingDraftBlocks={editingDraftBlocks} + editingDelegationOverrides={editingDelegationOverrides} isEditingQueueItem={isEditingQueueItem} onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} diff --git a/src/components/chat/composer/agent-delegation-config-popover.test.tsx b/src/components/chat/composer/agent-delegation-config-popover.test.tsx new file mode 100644 index 0000000000..9af677237c --- /dev/null +++ b/src/components/chat/composer/agent-delegation-config-popover.test.tsx @@ -0,0 +1,320 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { useState } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import type { AgentDelegationDefaults, AgentOptionsSnapshot } from "@/lib/types" + +import { AgentDelegationConfigPopover } from "./agent-delegation-config-popover" + +// Module-level holder the tests rewrite per case (the vi.mock factory hoists +// above const declarations, so it must only dereference this lazily — inside +// the mocked hook body, not at factory time). +const mockedSnapshot: { + value: AgentOptionsSnapshot | null + loading: boolean + error: string | null +} = { value: null, loading: false, error: null } + +vi.mock("@/components/automations/use-agent-options", () => ({ + useAgentOptions: vi.fn(() => ({ + snapshot: mockedSnapshot.value, + loading: mockedSnapshot.loading, + error: mockedSnapshot.error, + reload: () => {}, + ensure: async () => mockedSnapshot.value, + })), +})) + +const snapshot: AgentOptionsSnapshot = { + modes: null, + config_options: [ + { + id: "model", + name: "Model", + kind: { + type: "select", + current_value: "claude-sonnet-4-5", + options: [ + { value: "claude-sonnet-4-5", name: "Sonnet 4.5" }, + { value: "claude-opus-4-1", name: "Opus 4.1" }, + ], + groups: [], + }, + }, + { + id: "permission_mode", + name: "Permission mode", + kind: { + type: "select", + current_value: "default", + options: [ + { value: "default", name: "Default" }, + { value: "plan", name: "Plan" }, + ], + groups: [], + }, + }, + ], + available_commands: [], +} + +const globalDefault: AgentDelegationDefaults = { + config_values: { model: "claude-opus-4-1" }, +} + +function renderPopover(overrides?: { + value?: AgentDelegationDefaults | null + onChange?: (next: AgentDelegationDefaults | null) => void + globalDefault?: AgentDelegationDefaults | null + globalDefaultLoading?: boolean + globalDefaultError?: string | null + onRetryGlobalDefault?: () => void + agentType?: "claude_code" | "cline" +}) { + const onChange = + overrides?.onChange ?? + vi.fn<(next: AgentDelegationDefaults | null) => void>() + const utils = render( + + {}} + /> + + ) + return { onChange, ...utils } +} + +/** Drive one Radix Select: click its (labeled) trigger, then the option row. + * `AgentConfigSection`'s stacked rows pair a bare `