From 5cecab0c72c51a87d15caa693cf73244caa9d505 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 22:16:00 +0800 Subject: [PATCH 01/29] feat(db): add parent_tool_use_id and delegation_call_id for sub-agent delegation Adds migration m20260522_000001 with two nullable text columns on conversation; mirrors them into ConversationSummary / DbConversationSummary (plus surfaces the existing parent_id on the public summary); fills None at all 8 parser sites and the 3 existing ActiveModel construction sites. Adds round-trip tests covering both the populated and default-null cases. Phase 1 of multi-agent delegation; see docs/superpowers/specs/2026-05-22-multi-agent-delegation-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/manager.rs | 2 + src-tauri/src/db/entities/conversation.rs | 2 + .../m20260522_000001_delegation_columns.rs | 54 ++++++++++++++ src-tauri/src/db/migration/mod.rs | 2 + .../src/db/service/conversation_service.rs | 5 ++ src-tauri/src/db/service/import_service.rs | 2 + src-tauri/src/models/conversation.rs | 12 +++ src-tauri/src/parsers/claude.rs | 6 ++ src-tauri/src/parsers/cline.rs | 6 ++ src-tauri/src/parsers/codex.rs | 6 ++ src-tauri/src/parsers/gemini.rs | 3 + src-tauri/src/parsers/openclaw.rs | 6 ++ src-tauri/src/parsers/opencode.rs | 3 + src-tauri/tests/delegation_columns.rs | 73 +++++++++++++++++++ 14 files changed, 182 insertions(+) create mode 100644 src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs create mode 100644 src-tauri/tests/delegation_columns.rs diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 0efd1cd7e..863eb89b1 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -878,6 +878,8 @@ impl ConnectionManager { git_branch: Set(git_branch), external_id: Set(Some(original_for_tx)), parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), message_count: Set(0), created_at: Set(now), updated_at: Set(now), diff --git a/src-tauri/src/db/entities/conversation.rs b/src-tauri/src/db/entities/conversation.rs index d38fa0ecc..054f25994 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -28,6 +28,8 @@ pub struct Model { pub git_branch: Option, pub external_id: Option, pub parent_id: Option, + pub parent_tool_use_id: Option, + pub delegation_call_id: Option, pub message_count: i32, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, diff --git a/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs b/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs new file mode 100644 index 000000000..a19357140 --- /dev/null +++ b/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs @@ -0,0 +1,54 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .add_column(ColumnDef::new(Conversation::ParentToolUseId).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .add_column(ColumnDef::new(Conversation::DelegationCallId).text().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .drop_column(Conversation::DelegationCallId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .drop_column(Conversation::ParentToolUseId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Conversation { + Table, + ParentToolUseId, + DelegationCallId, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 49825c781..86b92d417 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -16,6 +16,7 @@ mod m20260424_000001_folder_color; mod m20260424_000002_quick_message; mod m20260513_000001_remote_workspace_connection; mod m20260518_000001_model_provider_single_type_and_model; +mod m20260522_000001_delegation_columns; pub struct Migrator; #[async_trait::async_trait] @@ -38,6 +39,7 @@ impl MigratorTrait for Migrator { Box::new(m20260424_000002_quick_message::Migration), Box::new(m20260513_000001_remote_workspace_connection::Migration), Box::new(m20260518_000001_model_provider_single_type_and_model::Migration), + Box::new(m20260522_000001_delegation_columns::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 7056905c9..996c51a04 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -30,6 +30,8 @@ pub async fn create( git_branch: Set(git_branch), external_id: Set(None), parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), message_count: Set(0), created_at: Set(now), updated_at: Set(now), @@ -152,6 +154,9 @@ fn conv_to_summary(r: conversation::Model) -> DbConversationSummary { message_count: r.message_count as u32, created_at: r.created_at, updated_at: r.updated_at, + parent_id: r.parent_id, + parent_tool_use_id: r.parent_tool_use_id, + delegation_call_id: r.delegation_call_id, } } diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index c9cbc49ff..967101b08 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -89,6 +89,8 @@ pub async fn import_local_conversations( git_branch: Set(summary.git_branch.clone()), external_id: Set(Some(summary.id.clone())), parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), message_count: Set(summary.message_count as i32), created_at: Set(created_at), updated_at: Set(updated_at), diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index b1c5112bf..50909d0c1 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -16,6 +16,12 @@ pub struct ConversationSummary { pub message_count: u32, pub model: Option, pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegation_call_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -31,6 +37,12 @@ pub struct DbConversationSummary { pub message_count: u32, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegation_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 98e82dbf6..40d16c3a9 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -369,6 +369,9 @@ impl ClaudeParser { message_count, model, git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, })) } } @@ -874,6 +877,9 @@ impl ClaudeParser { message_count: turns.len() as u32, model, git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index a972d96de..922666966 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -187,6 +187,9 @@ impl AgentParser for ClineParser { message_count, model, git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }); } @@ -338,6 +341,9 @@ impl AgentParser for ClineParser { message_count: turns.len() as u32, model: default_model, git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 64ba87a5d..bc32e4ab5 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -169,6 +169,9 @@ impl CodexParser { message_count, model, git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, })) } } @@ -1381,6 +1384,9 @@ impl CodexParser { message_count: turns.len() as u32, model, git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index fd96a0bd9..bcd09d03c 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -423,6 +423,9 @@ impl GeminiParser { message_count: messages.len() as u32, model, git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }) } diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index 9211ba33d..e72a08c3d 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -495,6 +495,9 @@ impl OpenClawParser { message_count, model: session_meta.and_then(|m| m.model.clone()), git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }); } @@ -688,6 +691,9 @@ impl OpenClawParser { message_count: turns.len() as u32, model, git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index 83625a0f6..6bda97679 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -103,6 +103,9 @@ impl OpenCodeParser { message_count, model: normalize_optional_string(model), git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, }) } diff --git a/src-tauri/tests/delegation_columns.rs b/src-tauri/tests/delegation_columns.rs new file mode 100644 index 000000000..4e419a382 --- /dev/null +++ b/src-tauri/tests/delegation_columns.rs @@ -0,0 +1,73 @@ +//! Verifies the m20260522 migration added `parent_tool_use_id` and +//! `delegation_call_id` columns on `conversation`, and they round-trip via the +//! SeaORM entity. + +use codeg_lib::db::entities::conversation; +use codeg_lib::db::test_helpers::{fresh_in_memory_db, seed_folder}; +use codeg_lib::models::agent::AgentType; +use sea_orm::{ActiveModelTrait, EntityTrait, NotSet, Set}; + +#[tokio::test] +async fn delegation_columns_round_trip() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-delegation-test").await; + + let agent_type_str = serde_json::to_value(AgentType::ClaudeCode) + .unwrap() + .as_str() + .unwrap() + .to_string(); + let now = chrono::Utc::now(); + let active = conversation::ActiveModel { + id: NotSet, + folder_id: Set(folder_id), + title: Set(Some("delegation child".to_string())), + agent_type: Set(agent_type_str), + status: Set(conversation::ConversationStatus::InProgress), + model: Set(None), + git_branch: Set(None), + external_id: Set(None), + parent_id: Set(Some(42)), + parent_tool_use_id: Set(Some("toolu_abc123".to_string())), + delegation_call_id: Set(Some("00000000-0000-0000-0000-000000000001".to_string())), + message_count: Set(0), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + }; + let inserted = active.insert(&db.conn).await.expect("insert"); + let id = inserted.id; + + let fetched = conversation::Entity::find_by_id(id) + .one(&db.conn) + .await + .expect("query ok") + .expect("row exists"); + assert_eq!(fetched.parent_id, Some(42)); + assert_eq!(fetched.parent_tool_use_id.as_deref(), Some("toolu_abc123")); + assert_eq!( + fetched.delegation_call_id.as_deref(), + Some("00000000-0000-0000-0000-000000000001") + ); +} + +#[tokio::test] +async fn delegation_columns_default_to_null_on_existing_create() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-delegation-null").await; + // The existing create helper does not set the new columns; verify they default to None. + let conv_id = codeg_lib::db::test_helpers::seed_conversation( + &db, + folder_id, + AgentType::ClaudeCode, + ) + .await; + let fetched = conversation::Entity::find_by_id(conv_id) + .one(&db.conn) + .await + .expect("query ok") + .expect("row exists"); + assert_eq!(fetched.parent_id, None); + assert_eq!(fetched.parent_tool_use_id, None); + assert_eq!(fetched.delegation_call_id, None); +} From ecb9186e4f8a15abf65d32658e87eb167330ef2e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 22:38:12 +0800 Subject: [PATCH 02/29] feat(acp): delegation event variants and parent-binding fields on ConversationLinked - Extend ConversationLinked payload with optional parent_conversation_id (i32) and parent_tool_use_id (String). Both serialize-skip when None so existing wire consumers see no change. - New variants DelegationStarted / DelegationCompleted with a DelegationResultSummary tagged enum (ok { duration_ms } / err { error_code }). - Patch all 7 ConversationLinked construction/pattern sites + extend the apply_event match in SessionState to ignore the new delegation events (broker handles them out of band). - Document the meta["codeg.delegation"] convention on ToolCallState. Phase 2 of multi-agent delegation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/lifecycle.rs | 2 ++ src-tauri/src/acp/manager.rs | 6 +++++ src-tauri/src/acp/session_state.rs | 30 ++++++++++++++++++++++++- src-tauri/src/acp/types.rs | 35 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 66a41a02a..90429c046 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -953,6 +953,8 @@ mod tests { assert!(is_lifecycle_relevant(&AcpEvent::ConversationLinked { conversation_id: 1, folder_id: 1, + parent_conversation_id: None, + parent_tool_use_id: None, })); assert!(is_lifecycle_relevant(&AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected, diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 863eb89b1..09446a669 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -529,6 +529,8 @@ impl ConnectionManager { AcpEvent::ConversationLinked { conversation_id: caller_conv_id, folder_id: caller_folder_id, + parent_conversation_id: None, + parent_tool_use_id: None, }, ) .await; @@ -553,6 +555,8 @@ impl ConnectionManager { AcpEvent::ConversationLinked { conversation_id: row.id, folder_id, + parent_conversation_id: None, + parent_tool_use_id: None, }, ) .await; @@ -1262,6 +1266,7 @@ mod tests { AcpEvent::ConversationLinked { conversation_id, folder_id: emitted_folder, + .. } => { assert_eq!(conversation_id, pre_existing.id); assert_eq!(emitted_folder, folder_id); @@ -1401,6 +1406,7 @@ mod tests { AcpEvent::ConversationLinked { conversation_id, folder_id: emitted_folder, + .. } => { assert_eq!(emitted_folder, folder_id); conversation_id diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 126f5a4a4..1ea1c2b8a 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -57,6 +57,22 @@ pub struct ToolCallState { /// ACP extensibility metadata. Used by frontend Phase 1 parent /// extraction. `None` if the agent didn't supply it. Same partial-update /// preservation semantic as `locations`. + /// + /// Convention used by codeg's multi-agent delegation (the `delegate_to_agent` + /// MCP tool) — `DelegationBroker` writes the following object under + /// `meta["codeg.delegation"]` on the parent's active tool call: + /// + /// ```jsonc + /// { + /// "child_connection_id": "", + /// "child_conversation_id": , + /// "status": "pending" | "running" | "completed" | "failed" + /// } + /// ``` + /// + /// The frontend reads this to render "Delegating to …" on the live + /// tool-call, and to anchor the inline `` to the + /// correct child conversation. pub meta: Option, /// Latest images attached to this tool call (e.g. codex-acp v0.14+ /// image generation). Replace-on-update semantics matching `content`: @@ -430,6 +446,7 @@ impl SessionState { AcpEvent::ConversationLinked { conversation_id, folder_id, + .. } => { self.conversation_id = Some(*conversation_id); self.folder_id = Some(*folder_id); @@ -464,8 +481,15 @@ impl SessionState { } AcpEvent::ClaudeSdkMessage { .. } | AcpEvent::Error { .. } - | AcpEvent::SessionLoadFailed { .. } => { + | AcpEvent::SessionLoadFailed { .. } + | AcpEvent::DelegationStarted { .. } + | AcpEvent::DelegationCompleted { .. } => { // 这些事件不直接修改 SessionState 的可见字段。 + // Delegation events: parent/child bookkeeping happens in + // DelegationBroker; SessionState only mirrors the in-flight + // `delegate_to_agent` tool call through `ToolCallState.meta` + // (key `codeg.delegation`), updated by the ToolCall / + // ToolCallUpdate handlers above. } } self.last_activity_at = Utc::now(); @@ -789,6 +813,8 @@ mod tests { s.apply_event(&AcpEvent::ConversationLinked { conversation_id: 7, folder_id: 3, + parent_conversation_id: None, + parent_tool_use_id: None, }); let before = s.to_snapshot(); let before_status = s.status.clone(); @@ -828,6 +854,8 @@ mod tests { s.apply_event(&AcpEvent::ConversationLinked { conversation_id: 42, folder_id: 7, + parent_conversation_id: None, + parent_tool_use_id: None, }); assert_eq!(s.conversation_id, Some(42)); assert_eq!(s.folder_id, Some(7)); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 26a663de7..9ebcc84be 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -134,9 +134,17 @@ pub enum AcpEvent { /// once per connection lifetime, on first prompt that creates the row. /// Frontend uses this to associate the connection_id with conversation_id /// without polling the DB. + /// + /// `parent_conversation_id` / `parent_tool_use_id` are set when the row was + /// created as a delegation child (see `DelegationLink` in + /// `acp::delegation`); they are `None` for normal top-level conversations. ConversationLinked { conversation_id: i32, folder_id: i32, + #[serde(skip_serializing_if = "Option::is_none", default)] + parent_conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + parent_tool_use_id: Option, }, /// Backend has transitioned the conversation row's `status` column. /// Emitted by `send_prompt_linked` (`InProgress`) and the lifecycle @@ -192,6 +200,33 @@ pub enum AcpEvent { AvailableCommands { commands: Vec }, /// Session usage/context window updated during conversation UsageUpdate { used: u64, size: u64 }, + /// A `delegate_to_agent` MCP tool call from the parent agent has spawned a + /// child sub-session and the child's prompt is in flight. Emitted as soon + /// as the broker registers the pending call. The frontend uses this to + /// build the parent ↔ child mapping for inline rendering. + DelegationStarted { + parent_connection_id: String, + parent_tool_use_id: String, + child_connection_id: String, + child_conversation_id: i32, + agent_type: crate::models::agent::AgentType, + }, + /// The child sub-session has finished (or errored / timed out / been + /// canceled). The MCP tool_result has been delivered to the parent agent. + DelegationCompleted { + parent_connection_id: String, + parent_tool_use_id: String, + child_connection_id: String, + child_conversation_id: i32, + result: DelegationResultSummary, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DelegationResultSummary { + Ok { duration_ms: u64 }, + Err { error_code: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] From c80ed6d9647399c29e6b9d9924122c361982caf2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 22:51:07 +0800 Subject: [PATCH 03/29] feat(acp): introduce ConnectionSpawner trait and DelegationLink carrier - New module acp/delegation/ with the spawner trait + DelegationLink carrier. The trait is the surface DelegationBroker depends on; production wires up via a new ConnectionManagerSpawner wrapper (manager + DB), tests use MockSpawner. - Extend send_prompt_linked with optional delegation arg; on the new-row branch it flows into create_with_delegation (new conversation_service entry point) and into the ConversationLinked event's parent_* fields. - Return type widened to Result, AcpError> so the spawner trait can hand the bound conversation id back to the broker; non-broker callers ignore the value. - Trait mock + 3 unit tests cover queued spawn/send results and unqueued-fail. Phase 3 of multi-agent delegation; broker arrives in Phase 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/delegation/mod.rs | 33 +++ src-tauri/src/acp/delegation/spawner.rs | 205 ++++++++++++++++++ src-tauri/src/acp/manager.rs | 191 ++++++++++++++-- src-tauri/src/acp/mod.rs | 1 + src-tauri/src/commands/acp.rs | 3 +- .../src/db/service/conversation_service.rs | 30 ++- src-tauri/src/web/handlers/acp.rs | 1 + 7 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/acp/delegation/mod.rs create mode 100644 src-tauri/src/acp/delegation/spawner.rs diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs new file mode 100644 index 000000000..4cbfc1fca --- /dev/null +++ b/src-tauri/src/acp/delegation/mod.rs @@ -0,0 +1,33 @@ +//! Multi-agent delegation: the parent agent's LLM can call the built-in MCP +//! tool `delegate_to_agent` to spawn a fresh ACP session of any (possibly +//! different) agent type, wait for its first turn to finish, and receive the +//! sub-agent's final assistant text as the MCP tool_result. +//! +//! The high-level wiring is: +//! +//! ```text +//! parent LLM ─┐ +//! │ ToolUse(delegate_to_agent, ...) +//! ▼ +//! parent CLI ──stdio──► codeg-mcp (per-launch companion binary) +//! │ +//! │ UDS / named pipe (token-authed) +//! ▼ +//! DelegationBroker (this module) +//! │ +//! │ ConnectionSpawner trait +//! ▼ +//! ConnectionManager.spawn_agent / send_prompt_linked +//! │ +//! ▼ +//! child ACP session ── TurnComplete ──┐ +//! │ +//! parent LLM ◄── MCP tool_result ◄── DelegationOutcome ◄───┘ +//! ``` +//! +//! v1 is one-shot (function-call semantics): after the child's first +//! `TurnComplete`, the broker resolves the pending call, sends `disconnect` +//! to the child, and returns. v2 will introduce `continue_with_session` / +//! `close_session` tools without protocol breakage. + +pub mod spawner; diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs new file mode 100644 index 000000000..b385b4c35 --- /dev/null +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -0,0 +1,205 @@ +//! `ConnectionSpawner` trait — the subset of `ConnectionManager` capabilities +//! that the delegation broker needs. Defined as a trait so: +//! +//! 1. The broker can be unit-tested with a `MockSpawner` (no real ACP +//! processes, no DB writes). +//! 2. Future cross-host / remote-agent work (v3+) can plug in a different +//! backend without touching the broker. +//! +//! The concrete impl on `Arc` lives in +//! `acp::manager` next to the existing `ConnectionManager` methods to keep +//! the manager's surface area contiguous. + +use async_trait::async_trait; + +use crate::models::agent::AgentType; + +/// Identifies a delegation call across the broker, the ACP layer, and the DB. +/// +/// `parent_conversation_id` is the **DB** id (i32) of the parent's conversation +/// row, not the ACP-side external session id. The child's new conversation +/// row will carry this as `parent_id` plus `parent_tool_use_id` (the MCP +/// tool_use_id from the parent's LLM-issued ToolUse) and `delegation_call_id` +/// (broker-internal UUID). +#[derive(Debug, Clone)] +pub struct DelegationLink { + pub parent_conversation_id: i32, + pub parent_tool_use_id: String, + pub delegation_call_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SpawnerError { + #[error("spawn failed: {0}")] + Spawn(String), + #[error("send prompt failed: {0}")] + Send(String), + #[error("disconnect failed: {0}")] + Disconnect(String), + #[error("cancel failed: {0}")] + Cancel(String), +} + +/// Capabilities the delegation broker needs from whatever owns the ACP +/// connections. v1 production impl is `Arc` (see +/// `acp/manager.rs`); tests use `mock::MockSpawner`. +/// +/// All methods are `async` because the production impl drives a Tokio runtime +/// and DB; the mock returns immediately. +#[async_trait] +pub trait ConnectionSpawner: Send + Sync { + /// Spawn a fresh child ACP connection of `agent_type` in `working_dir`. + /// No session resume, no preferred mode, no special env — delegation + /// children are always brand-new sessions. + /// + /// `parent_connection_id` identifies the parent ACP connection so the + /// production impl can inherit the parent's `EventEmitter` and + /// `owner_window_label` (both required by `ConnectionManager::spawn_agent`) + /// without leaking those types into the broker. If `working_dir` is + /// `None`, the impl may fall back to the parent connection's `working_dir`. + /// + /// Returns the new connection id (codeg-internal UUID, not the ACP + /// session id assigned by the agent). + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + ) -> Result; + + /// Send the delegation task as the child's first prompt. The + /// `DelegationLink` is persisted onto the new conversation row so the + /// lifecycle subscriber can later notify the broker on `TurnComplete`. + /// + /// Returns the new child conversation row id (i32). + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: DelegationLink, + ) -> Result; + + /// Cancel any in-flight prompt on the child connection. Idempotent: + /// calling on a connection with nothing in flight is a no-op success. + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError>; + + /// Tear down the child connection. Always called after the broker has + /// resolved (or failed) the pending call, to enforce v1's one-shot + /// semantics. + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError>; +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock { + use super::*; + use std::collections::VecDeque; + use tokio::sync::Mutex; + + /// In-memory spawner that returns pre-queued results and records every + /// `cancel` / `disconnect` it sees. Use `queue_spawn` / `queue_send` to + /// stage the next return value; calls without queued results fail loudly. + #[derive(Default)] + pub struct MockSpawner { + pub spawn_results: Mutex>>, + pub send_results: Mutex>>, + pub cancels: Mutex>, + pub disconnects: Mutex>, + } + + impl MockSpawner { + pub fn new() -> Self { + Self::default() + } + + pub async fn queue_spawn(&self, r: Result) { + self.spawn_results.lock().await.push_back(r); + } + + pub async fn queue_send(&self, r: Result) { + self.send_results.lock().await.push_back(r); + } + } + + #[async_trait] + impl ConnectionSpawner for MockSpawner { + async fn spawn( + &self, + _parent_connection_id: &str, + _agent_type: AgentType, + _working_dir: Option, + ) -> Result { + self.spawn_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued spawn result".into()))) + } + + async fn send_prompt_linked_for_delegation( + &self, + _conn_id: &str, + _task: String, + _link: DelegationLink, + ) -> Result { + self.send_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Send("no queued send result".into()))) + } + + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.cancels.lock().await.push(conn_id.to_string()); + Ok(()) + } + + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.disconnects.lock().await.push(conn_id.to_string()); + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[tokio::test] + async fn mock_records_cancel_and_disconnect() { + let m = MockSpawner::new(); + m.cancel("c1").await.unwrap(); + m.disconnect("c2").await.unwrap(); + assert_eq!(m.cancels.lock().await.as_slice(), &["c1".to_string()]); + assert_eq!(m.disconnects.lock().await.as_slice(), &["c2".to_string()]); + } + + #[tokio::test] + async fn mock_consumes_queued_spawn_results() { + let m = MockSpawner::new(); + m.queue_spawn(Ok("child-1".into())).await; + m.queue_spawn(Err(SpawnerError::Spawn("oh no".into()))).await; + let r1 = m + .spawn("parent-1", AgentType::ClaudeCode, Some("/tmp".into())) + .await + .unwrap(); + assert_eq!(r1, "child-1"); + let r2 = m + .spawn("parent-1", AgentType::Codex, None) + .await + .unwrap_err(); + assert!(matches!(r2, SpawnerError::Spawn(_))); + } + + #[tokio::test] + async fn mock_unqueued_spawn_fails_loudly() { + let m = MockSpawner::new(); + let r = m + .spawn("parent-1", AgentType::ClaudeCode, None) + .await + .unwrap_err(); + match r { + SpawnerError::Spawn(msg) => assert!(msg.contains("no queued")), + other => panic!("expected SpawnerError::Spawn, got {other:?}"), + } + } + } +} diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 09446a669..ea988593e 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -480,7 +480,8 @@ impl ConnectionManager { blocks: Vec, folder_id: Option, conversation_id: Option, - ) -> Result<(), AcpError> { + delegation: Option, + ) -> Result, AcpError> { // Caller-supplied conversation_id requires folder_id (we include it in // the emitted ConversationLinked event so subscribers don't have to // re-query the DB). Validate before touching any state. @@ -489,6 +490,15 @@ impl ConnectionManager { "conversation_id provided without folder_id".to_string(), )); } + // Delegation is only meaningful on the create-new-row branch — adopting + // an existing caller-supplied row already has its own (or no) parent + // linkage. Reject the combination loudly so a misuse from the broker + // doesn't silently drop the linkage. + if delegation.is_some() && conversation_id.is_some() { + return Err(AcpError::protocol( + "delegation link is incompatible with caller-supplied conversation_id".to_string(), + )); + } // Acquire the per-connection prompt lock for the entire link-check // + DB write + emit + cmd_tx.send sequence. Two concurrent prompts @@ -545,18 +555,31 @@ impl ConnectionManager { // silent fallback to working_dir-based find-or-create masked // contract violations. (None, Some(folder_id)) => { - let row = - conversation_service::create(&db.conn, folder_id, agent_type, None, None) - .await - .map_err(|e| AcpError::protocol(e.to_string()))?; + // Snapshot the delegation link before move-into-create: we + // still need the parent ids for the ConversationLinked + // event payload. + let parent_conversation_id_for_event = + delegation.as_ref().map(|d| d.parent_conversation_id); + let parent_tool_use_id_for_event = + delegation.as_ref().map(|d| d.parent_tool_use_id.clone()); + let row = conversation_service::create_with_delegation( + &db.conn, + folder_id, + agent_type, + None, + None, + delegation.clone(), + ) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; emit_with_state( &state_arc, &emitter, AcpEvent::ConversationLinked { conversation_id: row.id, folder_id, - parent_conversation_id: None, - parent_tool_use_id: None, + parent_conversation_id: parent_conversation_id_for_event, + parent_tool_use_id: parent_tool_use_id_for_event, }, ) .await; @@ -627,7 +650,7 @@ impl ConnectionManager { // PendingReview write also never fires — the row would be stuck // until a follow-up `send_prompt_linked` happened to re-flip it. match self.send_prompt_inner(conn_id, blocks).await { - Ok(()) => Ok(()), + Ok(()) => Ok(conversation_id_for_status), Err(send_err) => { if let Some(cid) = conversation_id_for_status { match conversation_service::update_status( @@ -1015,6 +1038,136 @@ impl ConnectionManager { } } +/// Production impl of `ConnectionSpawner` used by `DelegationBroker`. +/// +/// Bundles `Arc` with `Arc` because +/// `cancel` writes the cancelled status onto the conversation row, which +/// happens inside `ConnectionManager::cancel`. The wrapper exists so the +/// broker can depend on a small `dyn`-able interface instead of pulling +/// in the full `AppState` graph. +#[derive(Clone)] +pub struct ConnectionManagerSpawner { + pub manager: Arc, + pub db: Arc, +} + +#[async_trait::async_trait] +impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpawner { + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + ) -> Result { + use crate::acp::delegation::spawner::SpawnerError; + // Resolve the parent connection so we can inherit its emitter and + // owner_window. Falling back is not safe: a child whose emitter is + // wired to a different broadcaster would emit events the frontend + // never sees. + let (emitter, owner_window, parent_working_dir) = { + let conns = self.manager.connections.lock().await; + let parent = conns + .get(parent_connection_id) + .ok_or_else(|| { + SpawnerError::Spawn(format!( + "parent connection {parent_connection_id} not found" + )) + })?; + let pwd = { + let s = parent.state.read().await; + s.working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }; + (parent.emitter.clone(), parent.owner_window_label.clone(), pwd) + }; + let effective_working_dir = working_dir.or(parent_working_dir); + self.manager + .spawn_agent( + agent_type, + effective_working_dir, + None, + BTreeMap::new(), + owner_window, + emitter, + None, + BTreeMap::new(), + ) + .await + .map_err(|e| SpawnerError::Spawn(e.to_string())) + } + + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: crate::acp::delegation::spawner::DelegationLink, + ) -> Result { + use crate::acp::delegation::spawner::SpawnerError; + // The child has no caller-supplied conversation_id (it's brand new). + // folder_id must be None too — the manager's create-new-row branch + // requires folder_id, which we resolve from the child's working_dir + // via folder_service. Do that lookup here so the trait stays small. + let working_dir_pathbuf = { + let conns = self.manager.connections.lock().await; + let conn = conns + .get(conn_id) + .ok_or_else(|| SpawnerError::Send(format!("child {conn_id} not found")))?; + let s = conn.state.read().await; + s.working_dir.clone() + }; + let folder_path = working_dir_pathbuf + .ok_or_else(|| { + SpawnerError::Send( + "child connection has no working_dir; cannot derive folder_id".into(), + ) + })? + .to_string_lossy() + .to_string(); + let folder = crate::db::service::folder_service::add_folder(&self.db.conn, &folder_path) + .await + .map_err(|e| SpawnerError::Send(format!("add_folder: {e}")))?; + + let result = self + .manager + .send_prompt_linked( + &self.db, + conn_id, + vec![PromptInputBlock::Text { text: task }], + Some(folder.id), + None, + Some(link), + ) + .await + .map_err(|e| SpawnerError::Send(e.to_string()))?; + result.ok_or_else(|| { + SpawnerError::Send( + "send_prompt_linked succeeded but no conversation_id was bound".into(), + ) + }) + } + + async fn cancel( + &self, + conn_id: &str, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + self.manager + .cancel(&self.db.conn, conn_id) + .await + .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Cancel(e.to_string())) + } + + async fn disconnect( + &self, + conn_id: &str, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + self.manager + .disconnect(conn_id) + .await + .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Disconnect(e.to_string())) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1154,7 +1307,7 @@ mod tests { // First call: creates conversation row, sets state.conversation_id. // The mpsc send error after linking is expected and ignored here. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let snap = mgr .get_state(conn_id) @@ -1172,7 +1325,7 @@ mod tests { // Second call: ignores folder_id, does NOT create another row. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let snap2 = mgr .get_state(conn_id) @@ -1195,7 +1348,7 @@ mod tests { map.insert(conn_id.into(), fake_connection(conn_id, None)); } let result = mgr - .send_prompt_linked(&db, conn_id, vec![], None, None) + .send_prompt_linked(&db, conn_id, vec![], None, None, None) .await; assert!( result.is_err(), @@ -1249,7 +1402,7 @@ mod tests { // Send with caller-supplied conversation_id + folder_id. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre_existing.id)) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre_existing.id), None) .await; // No new conversation row was created. @@ -1292,7 +1445,7 @@ mod tests { .await; let err = mgr - .send_prompt_linked(&db, conn_id, vec![], None, Some(42)) + .send_prompt_linked(&db, conn_id, vec![], None, Some(42), None) .await .expect_err("should reject conversation_id without folder_id"); assert!(matches!(err, AcpError::Protocol(_))); @@ -1328,7 +1481,7 @@ mod tests { let before = count_conversation_rows(&db).await; let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre.id)) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre.id), None) .await; let after = count_conversation_rows(&db).await; assert_eq!(after, before); @@ -1398,7 +1551,7 @@ mod tests { // 2. ConversationStatusChanged(InProgress) [pre-send write] // 3. ConversationStatusChanged(Cancelled) [rollback after send failure] let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let env1 = recv_first_acp_event(&mut rx).await; @@ -1467,7 +1620,7 @@ mod tests { .unwrap(); let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let env4 = recv_first_acp_event(&mut rx).await; @@ -1818,12 +1971,12 @@ mod tests { tokio::join!( async { let _ = mgr_ref - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; }, async { let _ = mgr_ref - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; }, ); @@ -1944,7 +2097,7 @@ mod tests { let mut rx = subscribe_conn_stream(&mgr, conn_id).await; let result = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; assert!( matches!(result, Err(AcpError::ProcessExited)), diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 85fb935c6..8cb90ae45 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -1,5 +1,6 @@ pub mod binary_cache; pub mod connection; +pub mod delegation; pub mod error; pub mod event_stream; pub mod file_system_runtime; diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 36d6b8f8b..5e54971de 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -2466,8 +2466,9 @@ pub async fn acp_prompt( manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager - .send_prompt_linked(&db, &connection_id, blocks, folder_id, conversation_id) + .send_prompt_linked(&db, &connection_id, blocks, folder_id, conversation_id, None) .await + .map(|_| ()) } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 996c51a04..093f96312 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -14,12 +14,36 @@ pub async fn create( agent_type: AgentType, title: Option, git_branch: Option, +) -> Result { + create_with_delegation(conn, folder_id, agent_type, title, git_branch, None).await +} + +/// Mirror of [`create`] plus optional delegation linkage. Used by the +/// multi-agent broker when spawning a child sub-session — populates +/// `parent_id` / `parent_tool_use_id` / `delegation_call_id` so the lifecycle +/// subscriber and frontend can rebuild the parent ↔ child binding without +/// inspecting the live broker state. +pub async fn create_with_delegation( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, + delegation: Option, ) -> Result { let at_str = serde_json::to_value(agent_type) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); let now = Utc::now(); + let (parent_id, parent_tool_use_id, delegation_call_id) = match delegation { + Some(link) => ( + Some(link.parent_conversation_id), + Some(link.parent_tool_use_id), + Some(link.delegation_call_id), + ), + None => (None, None, None), + }; let model = conversation::ActiveModel { id: NotSet, folder_id: Set(folder_id), @@ -29,9 +53,9 @@ pub async fn create( model: Set(None), git_branch: Set(git_branch), external_id: Set(None), - parent_id: Set(None), - parent_tool_use_id: Set(None), - delegation_call_id: Set(None), + parent_id: Set(parent_id), + parent_tool_use_id: Set(parent_tool_use_id), + delegation_call_id: Set(delegation_call_id), message_count: Set(0), created_at: Set(now), updated_at: Set(now), diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index cccc32096..834f60bc6 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -184,6 +184,7 @@ pub async fn acp_prompt( params.blocks, params.folder_id, params.conversation_id, + None, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; From eeb0120244c39899b1855b096f093b01dc3cfdfc Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 23:05:57 +0800 Subject: [PATCH 04/29] feat(acp): DelegationBroker with depth check, timeout, parent-cancel cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a single delegate_to_agent call end-to-end against the Phase 3 ConnectionSpawner trait: - broker.rs: handle_request → depth pre-check → spawn → send_prompt_linked → park oneshot → race timeout vs complete_call. complete_call disconnects the child (v1 one-shot). cancel_by_parent fans out cancel+disconnect to every pending child of a given parent_connection_id. - types.rs: DelegationRequest / DelegationOutcome / DelegationError with stable wire-format error codes for the MCP tool_result payload. - depth.rs: generic async parent-chain walker, saturates at cap so a corrupted chain can't unbounded-walk the DB. - DbDepthLookup implements ConversationDepthLookup over AppDatabase; tests use an in-memory MockDepth + MockSpawner with no DB or runtime ACP. 14 new tests cover config round-trip, disabled fast-path, happy path with in-flight complete_call, spawn/send failure mapping, timeout cleanup, parent-cancel cascade, and depth limit rejection. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/delegation/broker.rs | 672 +++++++++++++++++++++++++ src-tauri/src/acp/delegation/depth.rs | 103 ++++ src-tauri/src/acp/delegation/mod.rs | 3 + src-tauri/src/acp/delegation/types.rs | 113 +++++ 4 files changed, 891 insertions(+) create mode 100644 src-tauri/src/acp/delegation/broker.rs create mode 100644 src-tauri/src/acp/delegation/depth.rs create mode 100644 src-tauri/src/acp/delegation/types.rs diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs new file mode 100644 index 000000000..370ecb63e --- /dev/null +++ b/src-tauri/src/acp/delegation/broker.rs @@ -0,0 +1,672 @@ +//! `DelegationBroker` — the coordination unit for multi-agent delegation. +//! +//! Lifecycle of a single call: +//! +//! 1. `handle_request` is the broker's only entry point. The MCP listener +//! feeds it the LLM-issued `delegate_to_agent` payload. +//! 2. Pre-checks: feature enabled? depth limit ok? Both failures return +//! immediately, no child session created. +//! 3. Spawn the child via [`ConnectionSpawner::spawn`]. +//! 4. Send the delegation task as the first prompt via +//! [`ConnectionSpawner::send_prompt_linked_for_delegation`]. The trailing +//! [`DelegationLink`] carries the parent's `tool_use_id` and a +//! broker-internal `call_id` (UUID) — these get persisted onto the new +//! conversation row. +//! 5. Park a `oneshot::Sender` keyed by `call_id`. The race is between: +//! - the listener calling [`DelegationBroker::complete_call`] on +//! `TurnComplete`, and +//! - the broker's own `tokio::time::timeout`. +//! 6. On any resolution, the child connection is disconnected. v1 is +//! explicitly one-shot — no session reuse. +//! +//! Cancellation cascade: when a parent session goes away (user-initiated +//! cancel, parent disconnect), the lifecycle subscriber calls +//! [`DelegationBroker::cancel_by_parent`] which fans out cancel + disconnect +//! to every pending child of that parent. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use tokio::sync::{oneshot, Mutex}; + +use crate::acp::delegation::spawner::{ConnectionSpawner, DelegationLink}; +use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationRequest}; + +/// Lookup the `parent_id` for a conversation. Abstracted so the broker can be +/// unit-tested against an in-memory chain without touching SeaORM. +#[async_trait] +pub trait ConversationDepthLookup: Send + Sync { + async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError>; +} + +#[derive(Debug, Clone)] +pub struct DelegationConfig { + pub enabled: bool, + /// Max chain depth a *new* delegation may exist at. With `depth_limit = 2` + /// the chain root → child → grandchild is allowed; the grandchild trying + /// to spawn a great-grandchild is rejected. See spec §5. + pub depth_limit: u32, + pub default_timeout: Duration, +} + +impl Default for DelegationConfig { + fn default() -> Self { + Self { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(600), + } + } +} + +struct PendingCall { + child_connection_id: String, + child_conversation_id: i32, + parent_connection_id: String, + #[allow(dead_code)] // surfaced via accessors and listener payloads in later phases + parent_tool_use_id: String, + tx: oneshot::Sender, +} + +#[derive(Default)] +struct PendingCalls { + inner: Mutex>, +} + +/// The broker is intentionally `Clone` (cheap — only `Arc`s inside) so +/// listener/handler code can hand copies to spawned tasks without lifetime +/// gymnastics. +#[derive(Clone)] +pub struct DelegationBroker { + spawner: Arc, + depth_lookup: Arc, + pending: Arc, + config: Arc>, +} + +impl DelegationBroker { + pub fn new( + spawner: Arc, + depth_lookup: Arc, + ) -> Self { + Self { + spawner, + depth_lookup, + pending: Arc::new(PendingCalls::default()), + config: Arc::new(Mutex::new(DelegationConfig::default())), + } + } + + pub async fn set_config(&self, cfg: DelegationConfig) { + *self.config.lock().await = cfg; + } + + pub async fn config_snapshot(&self) -> DelegationConfig { + self.config.lock().await.clone() + } + + /// Entry point. Drives the full lifecycle and returns whatever the parent + /// LLM should see as the `delegate_to_agent` tool_result. + pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome { + let cfg = self.config_snapshot().await; + if !cfg.enabled { + return DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "delegation disabled".into(), + }, + None, + ); + } + + // --- Depth pre-check ---------------------------------------------------- + // We walk up to `limit + 1` so we know whether the *new* child would + // sit at >= limit. Cycles/dead chains saturate at the cap. + let lookup = self.depth_lookup.clone(); + let parent_depth = match crate::acp::delegation::depth::compute_depth( + req.parent_conversation_id, + |id| { + let lookup = lookup.clone(); + async move { lookup.parent_of(id).await } + }, + cfg.depth_limit + 1, + ) + .await + { + Ok(d) => d, + Err(e) => return DelegationOutcome::from_err(e, None), + }; + // The child the broker is about to create would sit at `parent_depth + 1`. + // Reject when the *child* depth would equal or exceed the limit. + if parent_depth + 1 > cfg.depth_limit { + return DelegationOutcome::from_err( + DelegationError::DepthLimitExceeded { + current_depth: parent_depth, + limit: cfg.depth_limit, + }, + None, + ); + } + + let timeout = req + .timeout_seconds + .map(Duration::from_secs) + .unwrap_or(cfg.default_timeout); + let started_at = Instant::now(); + + // --- Spawn child connection -------------------------------------------- + let child_connection_id = match self + .spawner + .spawn( + &req.parent_connection_id, + req.agent_type, + req.working_dir.clone(), + ) + .await + { + Ok(id) => id, + Err(e) => { + return DelegationOutcome::from_err( + DelegationError::SpawnFailed(e.to_string()), + None, + ); + } + }; + + // --- Send linked prompt ------------------------------------------------ + let call_id = uuid::Uuid::new_v4().to_string(); + let link = DelegationLink { + parent_conversation_id: req.parent_conversation_id, + parent_tool_use_id: req.parent_tool_use_id.clone(), + delegation_call_id: call_id.clone(), + }; + let child_conversation_id = match self + .spawner + .send_prompt_linked_for_delegation(&child_connection_id, req.task.clone(), link) + .await + { + Ok(cid) => cid, + Err(e) => { + let _ = self.spawner.disconnect(&child_connection_id).await; + return DelegationOutcome::from_err( + DelegationError::SpawnFailed(e.to_string()), + None, + ); + } + }; + + // --- Register pending + race timeout vs completion -------------------- + let (tx, rx) = oneshot::channel(); + { + let mut map = self.pending.inner.lock().await; + map.insert( + call_id.clone(), + PendingCall { + child_connection_id: child_connection_id.clone(), + child_conversation_id, + parent_connection_id: req.parent_connection_id.clone(), + parent_tool_use_id: req.parent_tool_use_id.clone(), + tx, + }, + ); + } + + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(outcome)) => { + // complete_call already removed from `pending` and disconnected; + // belt-and-braces idempotent prune. + self.pending.inner.lock().await.remove(&call_id); + outcome + } + Ok(Err(_)) => { + // The sender was dropped before sending — should not happen in + // practice (complete_call always sends before drop), but be defensive. + self.pending.inner.lock().await.remove(&call_id); + let _ = self.spawner.disconnect(&child_connection_id).await; + DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "completion channel dropped".into(), + }, + Some(child_conversation_id), + ) + } + Err(_) => { + // Timeout: cancel in-flight, then disconnect, then return. + let _ = self.spawner.cancel(&child_connection_id).await; + let _ = self.spawner.disconnect(&child_connection_id).await; + self.pending.inner.lock().await.remove(&call_id); + DelegationOutcome::from_err( + DelegationError::Timeout { + elapsed_ms: started_at.elapsed().as_millis() as u64, + }, + Some(child_conversation_id), + ) + } + } + } + + /// Called by the child-session lifecycle subscriber on `TurnComplete` + /// (success path) or by error mappers (failure path). Idempotent — + /// calls on unknown `call_id` are silent no-ops. + pub async fn complete_call(&self, call_id: &str, outcome: DelegationOutcome) { + let entry = self.pending.inner.lock().await.remove(call_id); + if let Some(PendingCall { + child_connection_id, + tx, + .. + }) = entry + { + // v1 one-shot: always tear down the child. + let _ = self.spawner.disconnect(&child_connection_id).await; + let _ = tx.send(outcome); + } + } + + /// Cascade-cancel every pending delegation owned by `parent_connection_id`. + /// Used when a parent session disconnects or the user cancels the parent's + /// active prompt. + pub async fn cancel_by_parent(&self, parent_connection_id: &str) { + let drained: Vec = { + let mut map = self.pending.inner.lock().await; + let keys: Vec = map + .iter() + .filter(|(_, v)| v.parent_connection_id == parent_connection_id) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter() + .map(|k| map.remove(&k).expect("key just observed")) + .collect() + }; + for entry in drained { + let _ = self.spawner.cancel(&entry.child_connection_id).await; + let _ = self.spawner.disconnect(&entry.child_connection_id).await; + let _ = entry.tx.send(DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "parent canceled".into(), + }, + Some(entry.child_conversation_id), + )); + } + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn peek_first_pending_call_id(&self) -> Option { + self.pending.inner.lock().await.keys().next().cloned() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn pending_count(&self) -> usize { + self.pending.inner.lock().await.len() + } +} + +/// `ConversationDepthLookup` over the live `AppDatabase`. Used by the +/// production wiring; tests use the in-module `MockDepth`. +pub struct DbDepthLookup { + pub db: Arc, +} + +#[async_trait] +impl ConversationDepthLookup for DbDepthLookup { + async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError> { + use sea_orm::EntityTrait; + let row = crate::db::entities::conversation::Entity::find_by_id(conversation_id) + .one(&self.db.conn) + .await + .map_err(|e| DelegationError::SubagentRuntimeError(format!("db: {e}")))?; + Ok(row.and_then(|r| r.parent_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::{mock::MockSpawner, SpawnerError}; + use crate::acp::delegation::types::DelegationSuccess; + use crate::models::AgentType; + + /// Test-only `ConversationDepthLookup` that resolves against a flat + /// (id, parent_id) table. Unknown ids return `Ok(None)` to keep test + /// setup small. + struct MockDepth(Vec<(i32, Option)>); + + #[async_trait] + impl ConversationDepthLookup for MockDepth { + async fn parent_of(&self, id: i32) -> Result, DelegationError> { + Ok(self + .0 + .iter() + .find(|(c, _)| *c == id) + .and_then(|(_, p)| *p)) + } + } + + fn shallow_lookup() -> Arc { + // parent conversation is the root — depth = 0, no rejection. + Arc::new(MockDepth(vec![(1, None)])) as Arc + } + + fn request(parent_conv: i32, tool_use: &str) -> DelegationRequest { + DelegationRequest { + parent_connection_id: "parent-conn".into(), + parent_conversation_id: parent_conv, + parent_tool_use_id: tool_use.into(), + agent_type: AgentType::ClaudeCode, + task: "do x".into(), + working_dir: None, + timeout_seconds: Some(30), + } + } + + // -- Task 4.3 ----------------------------------------------------------- + + #[tokio::test] + async fn config_round_trip() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 5, + default_timeout: Duration::from_secs(120), + }) + .await; + let got = broker.config_snapshot().await; + assert!(!got.enabled); + assert_eq!(got.depth_limit, 5); + assert_eq!(got.default_timeout, Duration::from_secs(120)); + } + + #[tokio::test] + async fn disabled_returns_canceled_without_touching_spawner() { + let mock = Arc::new(MockSpawner::new()); + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), + _ => panic!("expected Err"), + } + assert!(mock.disconnects.lock().await.is_empty()); + } + + // -- Task 4.4: happy path ---------------------------------------------- + + #[tokio::test] + async fn happy_path_returns_ok_after_complete_call() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + + // Spin until the broker has registered the pending call so the test + // doesn't race the spawn/send awaits. + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "4".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 50, + token_usage: None, + }), + ) + .await; + + let outcome = driver.await.unwrap(); + match outcome { + DelegationOutcome::Ok(s) => { + assert_eq!(s.text, "4"); + assert_eq!(s.child_conversation_id, 42); + } + other => panic!("expected Ok, got {other:?}"), + } + assert_eq!(broker.pending_count().await, 0); + // complete_call disconnects the child once. + assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-conn-1"]); + } + + // -- Task 4.5: error paths --------------------------------------------- + + #[tokio::test] + async fn spawn_failure_maps_to_spawn_failed() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("nope".into()))).await; + let broker = DelegationBroker::new( + mock as Arc, + shallow_lookup(), + ); + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + other => panic!("expected Err, got {other:?}"), + } + } + + #[tokio::test] + async fn send_failure_after_spawn_disconnects_child() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Err(SpawnerError::Send("agent rejected prompt".into()))) + .await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + other => panic!("expected Err, got {other:?}"), + } + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); + } + + #[tokio::test] + async fn timeout_cancels_and_disconnects() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(99)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + let mut req = request(1, "pt-1"); + req.timeout_seconds = Some(1); + let outcome = broker.handle_request(req).await; + match outcome { + DelegationOutcome::Err { + code, + child_conversation_id, + .. + } => { + assert_eq!(code, "timeout"); + assert_eq!(child_conversation_id, Some(99)); + } + other => panic!("expected Timeout, got {other:?}"), + } + assert_eq!(mock.cancels.lock().await.as_slice(), &["c1"]); + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); + assert_eq!(broker.pending_count().await, 0); + } + + // -- Task 4.6: parent-cancel cascade ----------------------------------- + + #[tokio::test] + async fn parent_cancel_cancels_all_pending_children() { + let mock = Arc::new(MockSpawner::new()); + for i in 0..3 { + mock.queue_spawn(Ok(format!("c{i}"))).await; + mock.queue_send(Ok(100 + i)).await; + } + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + + let mut handles = Vec::new(); + for i in 0..3 { + let broker = broker.clone(); + handles.push(tokio::spawn(async move { + broker.handle_request(request(1, &format!("pt-{i}"))).await + })); + } + + // Wait until all three are parked. + while broker.pending_count().await < 3 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + + broker.cancel_by_parent("parent-conn").await; + for h in handles { + let outcome = h.await.unwrap(); + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), + other => panic!("expected canceled, got {other:?}"), + } + } + assert_eq!(mock.cancels.lock().await.len(), 3); + // Each child disconnects exactly once via cancel_by_parent. + assert_eq!(mock.disconnects.lock().await.len(), 3); + } + + #[tokio::test] + async fn cancel_by_parent_ignores_other_parents() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(200)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + + broker.cancel_by_parent("other-parent").await; + // No effect — pending entry still there. + assert_eq!(broker.pending_count().await, 1); + + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 200, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 10, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + // -- Task 4.7: depth limit --------------------------------------------- + + #[tokio::test] + async fn depth_limit_rejects_before_spawn() { + let mock = Arc::new(MockSpawner::new()); + // No queued spawn results — if the broker tries to spawn, it errors loudly. + // chain: 1 (root, None) <- 2 (child of 1) <- 3 (grandchild of 2). + // Parent = grandchild (id 3): parent_depth = 2. With limit = 2, child + // would sit at depth 3 → reject. + let lookup = Arc::new(MockDepth(vec![ + (1, None), + (2, Some(1)), + (3, Some(2)), + ])) as Arc; + let broker = DelegationBroker::new(mock as Arc, lookup); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + let outcome = broker.handle_request(request(3, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "depth_limit"), + other => panic!("expected depth_limit, got {other:?}"), + } + } + + #[tokio::test] + async fn depth_limit_allows_root() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(7)).await; + let lookup = Arc::new(MockDepth(vec![(1, None)])) as Arc; + let broker = DelegationBroker::new( + mock.clone() as Arc, + lookup, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 7, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } +} diff --git a/src-tauri/src/acp/delegation/depth.rs b/src-tauri/src/acp/delegation/depth.rs new file mode 100644 index 000000000..e076bb494 --- /dev/null +++ b/src-tauri/src/acp/delegation/depth.rs @@ -0,0 +1,103 @@ +//! Walk the conversation parent chain to compute delegation depth. +//! +//! The walker is generic over an async closure so the broker can plug in a +//! real DB lookup in production and a stub `Vec<(id, parent_id)>` in tests +//! without any extra trait plumbing. +//! +//! `cap` saturates the walk so a corrupted chain (cycle, deep history) can't +//! cause unbounded DB load. Callers pass `depth_limit + 1` — that's all the +//! broker ever needs to decide rejection. + +use std::future::Future; + +use crate::acp::delegation::types::DelegationError; + +pub async fn compute_depth( + start: i32, + mut parent_resolver: F, + cap: u32, +) -> Result +where + F: FnMut(i32) -> Fut, + Fut: Future, DelegationError>>, +{ + let mut current = start; + let mut depth = 0u32; + while depth < cap { + match parent_resolver(current).await? { + None => return Ok(depth), + Some(parent) => { + current = parent; + depth += 1; + } + } + } + Ok(depth) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn fake_chain(n: usize) -> Vec { + (0..n as i32).collect() + } + + fn parent_of(chain: &[i32], id: i32) -> Result, DelegationError> { + let idx = chain + .iter() + .position(|c| *c == id) + .expect("test resolver called with id not in chain"); + if idx == 0 { + Ok(None) + } else { + Ok(Some(chain[idx - 1])) + } + } + + #[tokio::test] + async fn depth_of_root_is_zero() { + let chain = fake_chain(1); + let resolver = |id: i32| { + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[0], resolver, 8).await.unwrap(); + assert_eq!(depth, 0); + } + + #[tokio::test] + async fn depth_of_grandchild_is_two() { + let chain = fake_chain(3); // root -> mid -> leaf + let resolver = |id: i32| { + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[2], resolver, 8).await.unwrap(); + assert_eq!(depth, 2); + } + + #[tokio::test] + async fn saturates_at_cap_without_walking_full_chain() { + let chain = fake_chain(20); + let calls = AtomicU32::new(0); + let resolver = |id: i32| { + calls.fetch_add(1, Ordering::SeqCst); + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[19], resolver, 3).await.unwrap(); + assert_eq!(depth, 3); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn resolver_error_propagates() { + let resolver = |_id: i32| async { + Err::, _>(DelegationError::SubagentRuntimeError("db down".into())) + }; + let err = compute_depth(42, resolver, 8).await.unwrap_err(); + assert!(matches!(err, DelegationError::SubagentRuntimeError(_))); + } +} diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index 4cbfc1fca..09b7666eb 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -30,4 +30,7 @@ //! to the child, and returns. v2 will introduce `continue_with_session` / //! `close_session` tools without protocol breakage. +pub mod broker; +pub mod depth; pub mod spawner; +pub mod types; diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs new file mode 100644 index 000000000..fdba251f1 --- /dev/null +++ b/src-tauri/src/acp/delegation/types.rs @@ -0,0 +1,113 @@ +//! Broker-facing request / outcome types. +//! +//! These cross two boundaries: +//! 1. The MCP companion serializes `DelegationRequest` → JSON-RPC params and +//! deserializes `DelegationOutcome` → MCP `tool_result`. +//! 2. The broker emits a structured outcome the listener can persist and +//! forward to the parent's tool_use_id. +//! +//! DB ids are `i32` to match the actual `conversation.id` / `conversation.parent_id` +//! column types — keeping them strongly typed here saves us a parse-or-die step +//! at every DB boundary. + +use serde::{Deserialize, Serialize}; + +use crate::models::AgentType; + +/// Everything the broker needs to dispatch a single delegation call. +/// +/// `parent_connection_id` is the codeg-internal ACP connection UUID for the +/// parent session (NOT the agent-assigned ACP session id). The broker uses it +/// to inherit the parent's EventEmitter/working_dir and to scope +/// `cancel_by_parent`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationRequest { + pub parent_connection_id: String, + pub parent_conversation_id: i32, + pub parent_tool_use_id: String, + pub agent_type: AgentType, + pub task: String, + pub working_dir: Option, + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsage { + pub input: u64, + pub output: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationSuccess { + pub text: String, + pub child_conversation_id: i32, + pub child_agent_type: AgentType, + pub turn_count: u32, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_usage: Option, +} + +/// Broker-internal failure modes. Serialized via the wrapping +/// [`DelegationOutcome::Err`] variant — the broker maps each into a stable +/// `code` string so the frontend / MCP consumer can pattern-match without +/// caring about the inner shape. +#[derive(Debug, Clone, thiserror::Error, Serialize, Deserialize)] +#[serde(tag = "code", content = "detail", rename_all = "snake_case")] +pub enum DelegationError { + #[error("depth limit exceeded ({current_depth} >= {limit})")] + DepthLimitExceeded { current_depth: u32, limit: u32 }, + #[error("invalid agent type")] + InvalidAgentType, + #[error("invalid working dir: {0}")] + InvalidWorkingDir(String), + #[error("spawn failed: {0}")] + SpawnFailed(String), + #[error("subagent runtime error: {0}")] + SubagentRuntimeError(String), + #[error("timeout after {elapsed_ms}ms")] + Timeout { elapsed_ms: u64 }, + #[error("canceled: {reason}")] + Canceled { reason: String }, + #[error("parent session is gone")] + ParentSessionGone, +} + +/// The single value the broker hands back to the listener / MCP companion. +/// `child_conversation_id` on the `Err` arm is best-effort — it's `Some` once +/// the broker successfully created the child DB row, even if the run later +/// fails or times out. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DelegationOutcome { + Ok(DelegationSuccess), + Err { + code: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + child_conversation_id: Option, + }, +} + +impl DelegationOutcome { + /// Project a `DelegationError` onto the wire-stable `code` string used by + /// the frontend and MCP companion. Keep these strings stable — they ship + /// to LLM context. + pub fn from_err(err: DelegationError, child_conversation_id: Option) -> Self { + let code = match &err { + DelegationError::DepthLimitExceeded { .. } => "depth_limit", + DelegationError::InvalidAgentType => "invalid_agent_type", + DelegationError::InvalidWorkingDir(_) => "invalid_working_dir", + DelegationError::SpawnFailed(_) => "spawn_failed", + DelegationError::SubagentRuntimeError(_) => "subagent_error", + DelegationError::Timeout { .. } => "timeout", + DelegationError::Canceled { .. } => "canceled", + DelegationError::ParentSessionGone => "canceled", + }; + DelegationOutcome::Err { + code: code.to_string(), + message: err.to_string(), + child_conversation_id, + } + } +} From 450e7fc15b5e8816cd9a17a5fbe4d64190e34396 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 23:22:26 +0800 Subject: [PATCH 05/29] feat(acp): codeg-mcp companion binary with stdio MCP + UDS broker round-trip Companion side of the multi-agent delegation flow. Caller agents launch codeg-mcp from their MCP config; the LLM sees a single `delegate_to_agent` tool whose tools/call gets forwarded to the main-process broker. - src/bin/codeg_mcp.rs: thin stdio loop, plain argv parsing (no clap), exits cleanly on EOF. - acp/delegation/companion.rs: JSON-RPC 2.0 dispatch (initialize, tools/list, tools/call), notification filtering, MCP tool_result rendering. Pulled into the lib so handle_line() is unit-testable without process spawning. - acp/delegation/transport.rs: length-prefixed JSON frame writer/reader plus cross-platform client_round_trip (UDS on unix, named pipe on windows). 16 MiB frame cap, in-memory + real-UDS tests. - acp/delegation/tool_schema.json: embedded delegate_to_agent schema with all 6 agent_type variants. Packaged as a third binary in the existing crate (matching codeg-server) rather than a workspace member, to avoid duplicating broker types and to share target/. New tests: 12 (3 transport + 9 companion). Full lib suite passes 419 tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/Cargo.toml | 5 + src-tauri/src/acp/delegation/companion.rs | 311 ++++++++++++++++++ src-tauri/src/acp/delegation/mod.rs | 2 + src-tauri/src/acp/delegation/tool_schema.json | 37 +++ src-tauri/src/acp/delegation/transport.rs | 186 +++++++++++ src-tauri/src/bin/codeg_mcp.rs | 128 +++++++ 6 files changed, 669 insertions(+) create mode 100644 src-tauri/src/acp/delegation/companion.rs create mode 100644 src-tauri/src/acp/delegation/tool_schema.json create mode 100644 src-tauri/src/acp/delegation/transport.rs create mode 100644 src-tauri/src/bin/codeg_mcp.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c233c419d..e83b3d9da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,11 @@ name = "codeg-server" path = "src/bin/codeg_server.rs" required-features = [] +[[bin]] +name = "codeg-mcp" +path = "src/bin/codeg_mcp.rs" +required-features = [] + [build-dependencies] tauri-build = { version = "2", features = [], optional = true } diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs new file mode 100644 index 000000000..e68735df1 --- /dev/null +++ b/src-tauri/src/acp/delegation/companion.rs @@ -0,0 +1,311 @@ +//! Companion-side MCP protocol — the bits that live inside the `codeg-mcp` +//! binary but are factored out into the library so they can be unit-tested +//! without spawning the binary. +//! +//! The companion speaks newline-delimited JSON-RPC 2.0 on stdio: +//! one request → one response per line. It exposes exactly one tool — +//! `delegate_to_agent` — whose schema is embedded at compile time from +//! [`tool_schema_json`]. +//! +//! Notifications (id = None) are silently ignored, matching MCP's expectation +//! that `notifications/initialized` etc. produce no response. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::acp::delegation::transport::{client_round_trip, BrokerRequest}; + +/// Static MCP tool schema. Lives next to this module so codeg-mcp ships +/// a single embedded copy — no runtime file IO, no version skew with the +/// broker's [`super::types::DelegationRequest`]. +pub const TOOL_SCHEMA_JSON: &str = include_str!("tool_schema.json"); + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + /// MCP notifications carry no `id`. We dispatch a response only when this + /// is `Some`. + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn ok(id: Value, result: Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: Some(result), + error: None, + } +} + +pub fn err(id: Value, code: i64, message: impl Into) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: None, + error: Some(JsonRpcError { + code, + message: message.into(), + data: None, + }), + } +} + +/// Process arguments threaded through every `tools/call` so the dispatcher +/// can build a [`BrokerRequest`] without re-parsing argv per call. +#[derive(Debug, Clone)] +pub struct CompanionContext { + pub parent_connection_id: String, + pub socket_path: String, + pub token: String, +} + +/// Parse, dispatch, and return the response JSON-RPC envelope, or `None` for +/// notifications. The caller is responsible for writing the line to stdout. +/// +/// Errors that happen before we have an `id` (parse failures with no `id` in +/// the parsed object) get reported with `id = null`, per JSON-RPC 2.0. +pub async fn handle_line(ctx: &CompanionContext, line: &str) -> Option { + let req: JsonRpcRequest = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + return Some(err( + Value::Null, + -32700, + format!("parse error: {e}"), + )); + } + }; + let id_opt = req.id.clone(); + let response = handle_request(ctx, req).await; + // A response is only sent when the request carried an id (i.e. it was a + // call, not a notification). For notifications we return None even on + // dispatch errors — that's what the MCP spec requires. + match (id_opt, response) { + (Some(_), resp) => resp, + (None, _) => None, + } +} + +async fn handle_request(ctx: &CompanionContext, req: JsonRpcRequest) -> Option { + let id = req.id.unwrap_or(Value::Null); + let resp = match req.method.as_str() { + "initialize" => ok( + id, + json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": "codeg-mcp", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { "tools": {} }, + }), + ), + "tools/list" => { + let tool: Value = match serde_json::from_str(TOOL_SCHEMA_JSON) { + Ok(v) => v, + Err(e) => return Some(err(id, -32603, format!("embedded schema invalid: {e}"))), + }; + ok(id, json!({ "tools": [tool] })) + } + "tools/call" => handle_tool_call(ctx, id, req.params).await, + _ => err(id, -32601, format!("method not found: {}", req.method)), + }; + Some(resp) +} + +async fn handle_tool_call(ctx: &CompanionContext, id: Value, params: Value) -> JsonRpcResponse { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name != "delegate_to_agent" { + return err(id, -32602, format!("unknown tool: {name}")); + } + let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); + // MCP passes the LLM-issued tool_use_id under `_meta.tool_use_id`. Without + // it the broker can't bind the eventual child outcome back to the parent's + // ToolUse — reject loudly. + let tool_use_id = params + .get("_meta") + .and_then(|m| m.get("tool_use_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if tool_use_id.is_empty() { + return err(id, -32602, "missing _meta.tool_use_id"); + } + + let req = BrokerRequest { + token: ctx.token.clone(), + parent_connection_id: ctx.parent_connection_id.clone(), + parent_tool_use_id: tool_use_id, + input: arguments, + }; + match client_round_trip(&ctx.socket_path, &req).await { + Ok(resp) => ok(id, render_tool_result(&resp.outcome)), + Err(e) => err(id, -32603, format!("broker round-trip failed: {e}")), + } +} + +/// Map a serialized [`super::types::DelegationOutcome`] into MCP `tools/call` +/// result content. Kept as a separate function so unit tests can assert the +/// mapping without a real socket. +pub fn render_tool_result(outcome: &Value) -> Value { + let kind = outcome.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + let is_error = kind == "err"; + let text = if is_error { + outcome + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("delegation failed") + .to_string() + } else { + outcome + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error, + "structuredContent": outcome.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> CompanionContext { + CompanionContext { + parent_connection_id: "p1".into(), + socket_path: "/tmp/nope.sock".into(), + token: "tok".into(), + } + } + + #[tokio::test] + async fn initialize_returns_protocol_version() { + let line = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert_eq!(result["serverInfo"]["name"], "codeg-mcp"); + } + + #[tokio::test] + async fn tools_list_returns_delegate_to_agent() { + let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let result = resp.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "delegate_to_agent"); + // Schema enumerates all 6 agent types. + let agents = tools[0]["inputSchema"]["properties"]["agent_type"]["enum"] + .as_array() + .unwrap(); + assert_eq!(agents.len(), 6); + } + + #[tokio::test] + async fn notification_produces_no_response() { + let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let resp = handle_line(&ctx(), line).await; + assert!(resp.is_none()); + } + + #[tokio::test] + async fn parse_error_returns_null_id_error() { + let line = "not json"; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32700); + assert!(e.message.contains("parse")); + assert_eq!(resp.id, Value::Null); + } + + #[tokio::test] + async fn unknown_method_returns_32601() { + let line = r#"{"jsonrpc":"2.0","id":9,"method":"resources/list"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32601); + } + + #[tokio::test] + async fn tools_call_with_unknown_tool_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params": { + "name": "other_tool", + "arguments": {}, + "_meta": {"tool_use_id": "tu1"} + } + }"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("other_tool")); + } + + #[tokio::test] + async fn tools_call_without_tool_use_id_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":4, + "method":"tools/call", + "params": { + "name": "delegate_to_agent", + "arguments": {"agent_type": "codex", "task": "x"} + } + }"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("_meta.tool_use_id")); + } + + #[test] + fn render_tool_result_maps_ok_outcome() { + let outcome = json!({"kind": "ok", "text": "hi", "child_conversation_id": 42}); + let rendered = render_tool_result(&outcome); + assert_eq!(rendered["isError"], false); + assert_eq!(rendered["content"][0]["text"], "hi"); + assert_eq!(rendered["structuredContent"]["child_conversation_id"], 42); + } + + #[test] + fn render_tool_result_maps_err_outcome() { + let outcome = json!({ + "kind": "err", + "code": "timeout", + "message": "timeout after 5000ms" + }); + let rendered = render_tool_result(&outcome); + assert_eq!(rendered["isError"], true); + assert_eq!(rendered["content"][0]["text"], "timeout after 5000ms"); + assert_eq!(rendered["structuredContent"]["code"], "timeout"); + } +} diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index 09b7666eb..cb980440a 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -31,6 +31,8 @@ //! `close_session` tools without protocol breakage. pub mod broker; +pub mod companion; pub mod depth; pub mod spawner; +pub mod transport; pub mod types; diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json new file mode 100644 index 000000000..893ad657d --- /dev/null +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -0,0 +1,37 @@ +{ + "name": "delegate_to_agent", + "description": "Delegate a self-contained task to another local AI coding agent and wait for its result. The sub-agent runs as an independent session with no access to this conversation's history; pass everything it needs in `task`. Use sparingly — each call spawns a full agent process and may take minutes.", + "inputSchema": { + "type": "object", + "required": ["agent_type", "task"], + "properties": { + "agent_type": { + "type": "string", + "enum": [ + "claude_code", + "codex", + "open_code", + "gemini", + "cline", + "open_claw" + ], + "description": "Which agent type to spawn for this sub-task." + }, + "task": { + "type": "string", + "description": "Complete, self-contained task description. The sub-agent does NOT see this conversation's prior messages." + }, + "working_dir": { + "type": "string", + "description": "Optional absolute path. Defaults to the parent session's working directory." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 3600, + "default": 600, + "description": "Per-call timeout. Defaults to 600 seconds." + } + } + } +} diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs new file mode 100644 index 000000000..35e501cd2 --- /dev/null +++ b/src-tauri/src/acp/delegation/transport.rs @@ -0,0 +1,186 @@ +//! Wire format for `codeg-mcp` companion ↔ main process round-trip over UDS +//! (Unix) or named pipe (Windows). +//! +//! The frame is dead simple: a little-endian `u32` byte length followed by +//! that many bytes of UTF-8 JSON. One request, one response — the companion +//! reopens the socket per `tools/call`. This trades a few extra connects for +//! a wire that's trivial to test and that doesn't need multiplexing +//! (a parent makes at most one delegation call at a time from the LLM's +//! perspective — the broker handles concurrency at a higher level). +//! +//! Why length-prefix instead of newline-delimited JSON? The LLM-issued +//! `task` arguments can contain newlines, and we'd rather avoid escaping +//! them into a single line. JSON-RPC over stdio uses newlines because +//! Content-Length headers add complexity; for an internal UDS we can do +//! better. + +use std::io; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// One delegation call's worth of input forwarded from the companion to the +/// main process. The main process re-validates `token` and maps +/// `parent_connection_id` to the live ACP connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerRequest { + /// Shared secret minted by the main process when it spawned the agent CLI; + /// the agent passes it through to the companion via `--token`. Rejects + /// anything else. + pub token: String, + /// codeg-internal ACP connection UUID for the parent session. + pub parent_connection_id: String, + /// The MCP `tool_use_id` for the LLM-issued `delegate_to_agent` call. + /// Used to bind the eventual child outcome back to the parent's + /// tool_use_id in the UI / DB. + pub parent_tool_use_id: String, + /// Raw `arguments` JSON from the MCP `tools/call` request, schema-shaped + /// per [`super::tool_schema_json`]. The main process re-parses into + /// [`super::types::DelegationRequest`]. + pub input: Value, +} + +/// The wrapped outcome the main process returns over the same socket. +/// `outcome` is a serialized [`super::types::DelegationOutcome`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerResponse { + pub outcome: Value, +} + +/// Maximum allowed frame size, 16 MiB. Guards against a misbehaving peer +/// allocating gigabytes when reading the length prefix. +pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// Write one length-prefixed JSON frame. +pub async fn write_frame(stream: &mut W, value: &T) -> io::Result<()> +where + W: AsyncWriteExt + Unpin, + T: Serialize, +{ + let bytes = serde_json::to_vec(value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("encode: {e}")))?; + let len: u32 = bytes + .len() + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame > u32::MAX"))?; + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +/// Read one length-prefixed JSON frame. Rejects frames larger than +/// [`MAX_FRAME_BYTES`]. +pub async fn read_frame(stream: &mut R) -> io::Result +where + R: AsyncReadExt + Unpin, + T: for<'de> Deserialize<'de>, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf) as usize; + if len > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame {len} bytes exceeds cap {MAX_FRAME_BYTES}"), + )); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await?; + serde_json::from_slice(&body) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}"))) +} + +/// One-shot client round-trip: connect, write the request, read the response, +/// drop the connection. +#[cfg(unix)] +pub async fn client_round_trip( + socket_path: &str, + req: &BrokerRequest, +) -> io::Result { + use tokio::net::UnixStream; + let mut stream = UnixStream::connect(socket_path).await?; + write_frame(&mut stream, req).await?; + read_frame(&mut stream).await +} + +/// Windows path uses named pipes; the address format is `\\.\pipe\`. +#[cfg(windows)] +pub async fn client_round_trip( + socket_path: &str, + req: &BrokerRequest, +) -> io::Result { + use tokio::net::windows::named_pipe::ClientOptions; + let mut stream = ClientOptions::new() + .open(socket_path) + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("open pipe: {e}")))?; + write_frame(&mut stream, req).await?; + read_frame(&mut stream).await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tokio::io::duplex; + + #[tokio::test] + async fn frame_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt1".into(), + input: json!({"agent_type": "codex", "task": "hi"}), + }; + write_frame(&mut a, &req).await.unwrap(); + let got: BrokerRequest = read_frame(&mut b).await.unwrap(); + assert_eq!(got.token, "tok"); + assert_eq!(got.input["agent_type"], "codex"); + } + + #[tokio::test] + async fn rejects_oversized_frame() { + let (mut a, mut b) = duplex(8); + // Write a length prefix larger than the cap, no body. + let bad_len: u32 = (MAX_FRAME_BYTES as u32) + 1; + a.write_all(&bad_len.to_le_bytes()).await.unwrap(); + a.flush().await.unwrap(); + let result: io::Result = read_frame(&mut b).await; + let err = result.expect_err("expected oversized frame to be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[cfg(unix)] + #[tokio::test] + async fn uds_round_trip() { + use tokio::net::UnixListener; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("codeg-mcp.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let server_path = path.to_string_lossy().to_string(); + + let server = tokio::spawn(async move { + let (mut conn, _) = listener.accept().await.unwrap(); + let req: BrokerRequest = read_frame(&mut conn).await.unwrap(); + assert_eq!(req.token, "tok"); + let resp = BrokerResponse { + outcome: json!({"kind": "ok", "text": "hello"}), + }; + write_frame(&mut conn, &resp).await.unwrap(); + }); + + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt1".into(), + input: json!({"agent_type": "codex", "task": "do x"}), + }; + let resp = client_round_trip(&server_path, &req).await.unwrap(); + assert_eq!(resp.outcome["kind"], "ok"); + assert_eq!(resp.outcome["text"], "hello"); + server.await.unwrap(); + } +} diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs new file mode 100644 index 000000000..c23784680 --- /dev/null +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -0,0 +1,128 @@ +//! `codeg-mcp` — the per-launch stdio MCP companion that an agent CLI runs +//! to surface the `delegate_to_agent` tool to its LLM. +//! +//! The agent's MCP config (injected by codeg via `load_mcp_servers_for_agent`) +//! spawns this binary with three required flags: +//! +//! codeg-mcp \ +//! --parent-connection-id \ +//! --socket-path \ +//! --token +//! +//! All three are required and the binary exits early if any is missing. +//! Everything heavyweight — JSON-RPC dispatch, UDS round-trip, MCP tool +//! schema — lives in `codeg_lib::acp::delegation::{companion, transport}` +//! so it's unit-testable without spawning a process. + +use std::io::Write; +use std::process::ExitCode; + +use codeg_lib::acp::delegation::companion::{handle_line, CompanionContext}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +struct Args { + parent_connection_id: String, + socket_path: String, + token: String, +} + +fn parse_args() -> Result { + let mut parent_connection_id = None; + let mut socket_path = None; + let mut token = None; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--parent-connection-id" => { + parent_connection_id = Some( + iter.next() + .ok_or_else(|| "--parent-connection-id requires a value".to_string())?, + ); + } + "--socket-path" => { + socket_path = Some( + iter.next() + .ok_or_else(|| "--socket-path requires a value".to_string())?, + ); + } + "--token" => { + token = Some( + iter.next() + .ok_or_else(|| "--token requires a value".to_string())?, + ); + } + "--help" | "-h" => { + println!( + "codeg-mcp --parent-connection-id --socket-path --token " + ); + std::process::exit(0); + } + other => return Err(format!("unknown arg: {other}")), + } + } + Ok(Args { + parent_connection_id: parent_connection_id + .ok_or_else(|| "missing --parent-connection-id".to_string())?, + socket_path: socket_path.ok_or_else(|| "missing --socket-path".to_string())?, + token: token.ok_or_else(|| "missing --token".to_string())?, + }) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: {e}"); + return ExitCode::from(2); + } + }; + let ctx = CompanionContext { + parent_connection_id: args.parent_connection_id, + socket_path: args.socket_path, + token: args.token, + }; + + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut lines = BufReader::new(stdin).lines(); + + loop { + let line = match lines.next_line().await { + Ok(Some(l)) => l, + Ok(None) => break, // parent closed stdin → graceful exit + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: read stdin: {e}"); + return ExitCode::from(1); + } + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(resp) = handle_line(&ctx, line).await { + match serde_json::to_string(&resp) { + Ok(serialized) => { + if let Err(e) = stdout.write_all(serialized.as_bytes()).await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: write stdout: {e}"); + return ExitCode::from(1); + } + if let Err(e) = stdout.write_all(b"\n").await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: write stdout: {e}"); + return ExitCode::from(1); + } + if let Err(e) = stdout.flush().await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: flush stdout: {e}"); + return ExitCode::from(1); + } + } + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: encode response: {e}"); + return ExitCode::from(1); + } + } + } + } + ExitCode::SUCCESS +} From 1df13a5742501f9e9635ff852d71d6b707f4395e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 22 May 2026 23:51:45 +0800 Subject: [PATCH 06/29] feat(acp): wire delegation listener, MCP injection, lifecycle, settings end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the integration phase: the Phase 5 codeg-mcp companion now reaches a running broker via UDS, the LLM-issued delegate_to_agent ToolUse spawns a child session, and its TurnComplete resolves the parent's tool_use_id. New modules: - acp/delegation/listener.rs: UDS / named-pipe accept loop, token registry (register/revoke/lookup/revoke_by_parent), ParentSessionLookup trait, process() with token + parent-conn + agent_type + task validation. 7 unit tests cover the rejection paths and a duplex-stream happy path. - commands/delegation.rs: persistence layer for the 3 settings keys (enabled / depth_limit / default_timeout_seconds) backed by app_metadata, Tauri commands get/set_delegation_settings, clamp helper, broker re-application on save. 4 unit tests round-trip storage + clamp behavior. - tests/delegation_e2e_uds.rs: full UDS round-trip with a mock spawner — happy path + invalid-token rejection. Wiring: - AppState gains delegation_broker / delegation_tokens / delegation_socket_path; app_state::build_delegation_stack() constructs the full stack (broker, depth lookup, token registry, PID-scoped socket) and installs the injection onto ConnectionManager via a OnceLock-backed install_delegation so all 5 spawn_agent call sites pick it up without parameter threading. - Both codeg-server bootstrap and Tauri lib.rs setup() build the stack, apply_persisted_config() before listener bind, and spawn run(). Tauri also .manage()s broker/tokens/socket-path for HTTP/Tauri command lookup. - connection.rs: run_connection + spawn_agent_connection gained an Option arg. After init handshake, inject_codeg_delegate_mcp registers a per-launch token, appends a stdio McpServer entry pointing at the codeg-mcp binary colocated next to current_exe, and stashes the token on SessionState.delegation_token. On run_connection exit the token is revoked and broker.cancel_by_parent fans out cascade-cancel. - ConnectionManagerParentLookup impl in manager.rs reads the live session state to answer "what conversation is parent X currently in?" - send_prompt_linked emits AcpEvent::DelegationStarted on the new-row branch when a DelegationLink is supplied (Task 6.7). - lifecycle.rs: handle_event / handle_event_with_retry / connection_worker_loop / lifecycle_subscriber_task all take Option>. On TurnComplete for a delegation child, forward_turn_complete_to_broker maps stop_reason → DelegationOutcome, calls broker.complete_call (which disconnects the child per v1 one-shot semantics), and emits AcpEvent::DelegationCompleted. On Disconnected / Error, forward_disconnect_to_broker calls cancel_by_child_connection so the parent's tool_use_id doesn't dangle. - SessionState gains last_assistant_text — captured at TurnComplete just before live_message is cleared so the broker outcome carries real text instead of an empty stub. - broker: new cancel_by_child_connection method, symmetric to cancel_by_parent. Tests: 430 lib passing (+11 vs Phase 5), 2 e2e UDS, clippy clean across desktop + server features. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/connection.rs | 103 ++++- src-tauri/src/acp/delegation/broker.rs | 28 ++ src-tauri/src/acp/delegation/listener.rs | 541 +++++++++++++++++++++++ src-tauri/src/acp/delegation/mod.rs | 1 + src-tauri/src/acp/lifecycle.rs | 197 +++++++-- src-tauri/src/acp/manager.rs | 65 +++ src-tauri/src/acp/session_state.rs | 33 ++ src-tauri/src/app_state.rs | 64 ++- src-tauri/src/bin/codeg_server.rs | 42 +- src-tauri/src/commands/delegation.rs | 269 +++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 63 ++- src-tauri/src/web/mod.rs | 15 + src-tauri/tests/delegation_e2e_uds.rs | 184 ++++++++ 14 files changed, 1567 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/acp/delegation/listener.rs create mode 100644 src-tauri/src/commands/delegation.rs create mode 100644 src-tauri/tests/delegation_e2e_uds.rs diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 8f9ba2104..bb7a1c8bf 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -377,6 +377,7 @@ pub async fn spawn_agent_connection( connections: Arc>>, preferred_mode_id: Option, preferred_config_values: BTreeMap, + delegation_injection: Option, ) -> Result, AcpError> { // Create the authoritative session state up front. Subsequent emit_with_state // calls write through this state and increment its seq counter so the first @@ -452,6 +453,7 @@ pub async fn spawn_agent_connection( connection_id: cleanup_connection_id, }; + let delegation_for_cleanup = delegation_injection.clone(); let result = run_connection( agent, conn_id.clone(), @@ -464,9 +466,25 @@ pub async fn spawn_agent_connection( terminal_base_env, preferred_mode_id, preferred_config_values, + delegation_injection, ) .await; + // Revoke the per-launch token + cascade cancel any still-pending + // delegations owned by this parent connection. Both are best-effort: + // a missing token entry is a no-op, and `cancel_by_parent` is safe + // to call on an empty pending map. + if let Some(inj) = delegation_for_cleanup { + let token = { + let snap = state_clone.read().await; + snap.delegation_token.clone() + }; + if let Some(tok) = token { + inj.tokens.revoke(&tok).await; + } + inj.broker.cancel_by_parent(&conn_id).await; + } + if let Err(e) = result { let code = e.code().map(String::from); emit_with_state( @@ -815,6 +833,74 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { out } +/// Context the connection layer needs to inject the built-in `codeg-delegate` +/// MCP entry. Built once per `run_connection` from the live AppState pieces +/// (broker config, token registry, UDS path) and passed through. +/// +/// Optional because some test paths spin up `run_connection` without a +/// full delegation stack — those just skip injection. +#[derive(Clone)] +pub struct DelegationInjection { + pub broker: Arc, + pub tokens: Arc, + pub socket_path: PathBuf, +} + +/// Locate the `codeg-mcp` companion binary next to the running executable. +/// Falls back to the colocated path even when the file doesn't exist so the +/// spawn failure surfaces inside the agent process (with a clearer error +/// than a missing-path return here). +fn locate_codeg_mcp_binary() -> PathBuf { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)); + let filename = if cfg!(windows) { + "codeg-mcp.exe" + } else { + "codeg-mcp" + }; + exe_dir + .map(|d| d.join(filename)) + .unwrap_or_else(|| PathBuf::from(filename)) +} + +/// Append the built-in `codeg-delegate` MCP entry if delegation is enabled. +/// Returns the per-launch token that was registered (or `None` if injection +/// was skipped) so the caller can revoke it on connection teardown. +async fn inject_codeg_delegate_mcp( + servers: &mut Vec, + injection: &DelegationInjection, + parent_connection_id: &str, + working_dir: &Path, +) -> Option { + let cfg = injection.broker.config_snapshot().await; + if !cfg.enabled { + return None; + } + let token = uuid::Uuid::new_v4().to_string(); + injection + .tokens + .register( + token.clone(), + crate::acp::delegation::listener::TokenEntry { + parent_connection_id: parent_connection_id.to_string(), + working_dir: working_dir.to_path_buf(), + }, + ) + .await; + let mut server = McpServerStdio::new("codeg-delegate", locate_codeg_mcp_binary()); + server = server.args(vec![ + "--parent-connection-id".to_string(), + parent_connection_id.to_string(), + "--socket-path".to_string(), + injection.socket_path.to_string_lossy().to_string(), + "--token".to_string(), + token.clone(), + ]); + servers.push(McpServer::Stdio(server)); + Some(token) +} + /// Resolve an MCP server `command` to an absolute path. /// /// The ACP spec requires `McpServerStdio.command` to be an absolute path. @@ -925,6 +1011,7 @@ async fn run_connection( terminal_base_env: BTreeMap, preferred_mode_id: Option, preferred_config_values: BTreeMap, + delegation_injection: Option, ) -> Result<(), AcpError> { let pending_perms: PendingPermissions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); // `terminal_base_env` already filtered to just the credential helper @@ -1129,7 +1216,7 @@ async fn run_connection( // capabilities the agent just declared. Stdio is mandatory per // ACP spec; HTTP/SSE are gated on `mcp_capabilities.{http,sse}`. let mcp_caps = &init_resp.agent_capabilities.mcp_capabilities; - let mcp_servers: Vec = load_mcp_servers_for_agent(agent_type) + let mut mcp_servers: Vec = load_mcp_servers_for_agent(agent_type) .into_iter() .filter(|s| match s { McpServer::Stdio(_) => true, @@ -1159,6 +1246,20 @@ async fn run_connection( }) .collect(); + // Inject the built-in `codeg-delegate` MCP server. Stdio is + // unconditionally supported by the ACP wire — no `mcp_caps` + // filter needed. The returned token is stashed on the session + // state so connection teardown can revoke it. + let delegate_token = if let Some(inj) = delegation_injection.as_ref() { + inject_codeg_delegate_mcp(&mut mcp_servers, inj, &conn_id, &cwd).await + } else { + None + }; + if let Some(ref tok) = delegate_token { + let mut s = state.write().await; + s.delegation_token = Some(tok.clone()); + } + // Emit fork support capability emit_with_state( &state, diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 370ecb63e..407647059 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -263,6 +263,34 @@ impl DelegationBroker { } } + /// Resolve the pending delegation whose child matches + /// `child_connection_id` with a `canceled` outcome. Used when a child + /// session disconnects or errors out without firing a clean + /// TurnComplete — the parent's `tool_use_id` shouldn't dangle. + /// No-op when no matching entry exists. + pub async fn cancel_by_child_connection(&self, child_connection_id: &str) { + let drained: Vec = { + let mut map = self.pending.inner.lock().await; + let keys: Vec = map + .iter() + .filter(|(_, v)| v.child_connection_id == child_connection_id) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter() + .map(|k| map.remove(&k).expect("key just observed")) + .collect() + }; + for entry in drained { + let _ = self.spawner.disconnect(&entry.child_connection_id).await; + let _ = entry.tx.send(DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "child session ended without TurnComplete".into(), + }, + Some(entry.child_conversation_id), + )); + } + } + /// Cascade-cancel every pending delegation owned by `parent_connection_id`. /// Used when a parent session disconnects or the user cancels the parent's /// active prompt. diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs new file mode 100644 index 000000000..b3eae2234 --- /dev/null +++ b/src-tauri/src/acp/delegation/listener.rs @@ -0,0 +1,541 @@ +//! Main-process side of the `codeg-mcp` round-trip: accept UDS / named-pipe +//! connections from companion processes, validate the per-launch token, +//! resolve the parent's current conversation, and hand off to the broker. +//! +//! The listener is intentionally tiny — most of the work (depth checking, +//! spawn lifecycle, timeout, cancellation) happens inside +//! [`DelegationBroker`]. The listener is the boundary between the wire and +//! the broker, plus the place where the per-launch token policy is enforced. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::RwLock; + +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::transport::{read_frame, write_frame, BrokerRequest, BrokerResponse}; +use crate::acp::delegation::types::{DelegationOutcome, DelegationRequest}; +use crate::models::AgentType; + +/// Pluggable "what conversation is this parent currently in?" lookup. The +/// production impl wraps `ConnectionManager.get_state`; tests use an +/// in-memory map. +/// +/// Kept as a trait so the listener can be unit-tested without spinning up a +/// real `ConnectionManager` or RwLock. +#[async_trait] +pub trait ParentSessionLookup: Send + Sync { + async fn current_conversation_id(&self, parent_connection_id: &str) -> Option; +} + +/// Per-launch token entry. Bound at MCP injection time and revoked on parent +/// connection teardown. +#[derive(Debug, Clone)] +pub struct TokenEntry { + pub parent_connection_id: String, + pub working_dir: PathBuf, +} + +#[derive(Default)] +pub struct TokenRegistry { + inner: RwLock>, +} + +impl TokenRegistry { + pub async fn register(&self, token: String, entry: TokenEntry) { + self.inner.write().await.insert(token, entry); + } + + pub async fn revoke(&self, token: &str) { + self.inner.write().await.remove(token); + } + + pub async fn lookup(&self, token: &str) -> Option { + self.inner.read().await.get(token).cloned() + } + + /// Drop every token whose `parent_connection_id` matches. Used on parent + /// connection teardown so a leaked token can't be reused. + pub async fn revoke_by_parent(&self, parent_connection_id: &str) { + let mut map = self.inner.write().await; + map.retain(|_, entry| entry.parent_connection_id != parent_connection_id); + } +} + +pub struct DelegationListener { + pub broker: Arc, + pub tokens: Arc, + pub parent_lookup: Arc, +} + +impl DelegationListener { + pub fn new( + broker: Arc, + tokens: Arc, + parent_lookup: Arc, + ) -> Arc { + Arc::new(Self { + broker, + tokens, + parent_lookup, + }) + } + + /// Run the accept loop until the socket is unbound. Errors on accept are + /// logged and the loop continues — a single bad connection can't bring + /// down the listener. + #[cfg(unix)] + pub async fn run(self: Arc, socket_path: PathBuf) -> std::io::Result<()> { + let _ = tokio::fs::remove_file(&socket_path).await; + if let Some(parent) = socket_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let listener = tokio::net::UnixListener::bind(&socket_path)?; + eprintln!( + "[delegation] listening on UDS {}", + socket_path.display() + ); + loop { + match listener.accept().await { + Ok((mut conn, _)) => { + let me = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = me.serve_one(&mut conn).await { + eprintln!("[delegation] connection failed: {e}"); + } + }); + } + Err(e) => { + eprintln!("[delegation] accept failed: {e}"); + // Brief backoff so a persistent accept error doesn't pin a core. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + } + + /// Windows variant: accept once, re-create the named pipe instance, repeat. + /// Tokio's named-pipe API exposes one server instance at a time and + /// requires re-binding after each connection. + #[cfg(windows)] + pub async fn run(self: Arc, socket_path: PathBuf) -> std::io::Result<()> { + use tokio::net::windows::named_pipe::ServerOptions; + let path_str = socket_path.to_string_lossy().to_string(); + loop { + let mut server = ServerOptions::new().create(&path_str)?; + if let Err(e) = server.connect().await { + eprintln!("[delegation] connect failed: {e}"); + continue; + } + let me = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = me.serve_one(&mut server).await { + eprintln!("[delegation] connection failed: {e}"); + } + }); + } + } + + /// Stream-generic per-connection handler. Exposed so unit tests can drive + /// it over `tokio::io::duplex` instead of a real socket. + pub async fn serve_one(&self, conn: &mut C) -> std::io::Result<()> + where + C: AsyncReadExt + AsyncWriteExt + Unpin, + { + let req: BrokerRequest = read_frame(conn).await?; + let outcome = self.process(req).await; + let resp = BrokerResponse { + outcome: serde_json::to_value(&outcome).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }; + write_frame(conn, &resp).await?; + Ok(()) + } + + async fn process(&self, req: BrokerRequest) -> DelegationOutcome { + // 1. Token + parent_connection_id consistency check. Treat both as + // "canceled" since the LLM can't usefully react to either — + // the parent has either been torn down or is impersonating. + let entry = match self.tokens.lookup(&req.token).await { + Some(e) => e, + None => return cancel("invalid token"), + }; + if entry.parent_connection_id != req.parent_connection_id { + return cancel("token does not match parent connection"); + } + + // 2. Resolve the parent's current conversation. Without one the + // broker can't link the child row to the parent. + let parent_conversation_id = match self + .parent_lookup + .current_conversation_id(&req.parent_connection_id) + .await + { + Some(id) => id, + None => return cancel("parent has no active conversation"), + }; + + // 3. Parse the delegate_to_agent arguments. Schema validation lives + // on the LLM side; we only enforce what the broker can't. + let agent_type = match req.input.get("agent_type").and_then(|v| v.as_str()) { + Some(raw) => match parse_agent_type(raw) { + Some(t) => t, + None => return invalid_agent_type(raw), + }, + None => return invalid_agent_type(""), + }; + let task = match req.input.get("task").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + return DelegationOutcome::Err { + code: "invalid_working_dir".into(), + message: "missing or empty task".into(), + child_conversation_id: None, + } + } + }; + let working_dir = req + .input + .get("working_dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + let timeout_seconds = req + .input + .get("timeout_seconds") + .and_then(|v| v.as_u64()); + + let delegation_req = DelegationRequest { + parent_connection_id: req.parent_connection_id, + parent_conversation_id, + parent_tool_use_id: req.parent_tool_use_id, + agent_type, + task, + working_dir, + timeout_seconds, + }; + self.broker.handle_request(delegation_req).await + } +} + +fn cancel(message: &str) -> DelegationOutcome { + DelegationOutcome::Err { + code: "canceled".into(), + message: message.into(), + child_conversation_id: None, + } +} + +fn invalid_agent_type(raw: &str) -> DelegationOutcome { + DelegationOutcome::Err { + code: "invalid_agent_type".into(), + message: if raw.is_empty() { + "missing agent_type".into() + } else { + format!("invalid agent_type: {raw}") + }, + child_conversation_id: None, + } +} + +fn parse_agent_type(raw: &str) -> Option { + serde_json::from_value(serde_json::Value::String(raw.to_string())).ok() +} + +/// Default socket path for the running process, scoped to PID so multiple +/// codeg instances on the same machine don't collide. +pub fn default_socket_path(temp_dir: &Path) -> PathBuf { + temp_dir.join(format!("codeg-delegation-{}.sock", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationConfig}; + use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner, SpawnerError}; + use crate::acp::delegation::types::{DelegationError, DelegationSuccess}; + use serde_json::json; + use std::time::Duration; + use tokio::io::duplex; + + struct AlwaysRootLookup; + #[async_trait] + impl ConversationDepthLookup for AlwaysRootLookup { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } + } + + struct StaticParentLookup(Option); + #[async_trait] + impl ParentSessionLookup for StaticParentLookup { + async fn current_conversation_id(&self, _parent_connection_id: &str) -> Option { + self.0 + } + } + + fn make_broker(mock: Arc) -> Arc { + Arc::new(DelegationBroker::new( + mock as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )) + } + + fn make_listener( + broker: Arc, + tokens: Arc, + parent_conversation: Option, + ) -> Arc { + DelegationListener::new( + broker, + tokens, + Arc::new(StaticParentLookup(parent_conversation)), + ) + } + + async fn make_request(input: serde_json::Value) -> BrokerRequest { + BrokerRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: "pt-1".into(), + input, + } + } + + #[tokio::test] + async fn invalid_token_rejected() { + let listener = make_listener( + make_broker(Arc::new(MockSpawner::new())), + Arc::new(TokenRegistry::default()), + Some(1), + ); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("invalid token")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn token_parent_mismatch_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "other-parent".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(make_broker(Arc::new(MockSpawner::new())), tokens, Some(1)); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("does not match")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn missing_parent_conversation_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + // parent_conversation = None: parent has no live conversation. + let listener = make_listener(make_broker(Arc::new(MockSpawner::new())), tokens, None); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("no active conversation")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn invalid_agent_type_rejected() { + 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(make_broker(Arc::new(MockSpawner::new())), tokens, Some(1)); + let outcome = listener + .process(make_request(json!({"agent_type": "garbage", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "invalid_agent_type"), + _ => panic!("expected invalid_agent_type"), + } + } + + #[tokio::test] + async fn happy_path_via_duplex_stream() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn".into())).await; + mock.queue_send(Ok(42)).await; + let broker = make_broker(mock.clone()); + 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)); + + // Make broker resolve from another task once the call lands. + let completer = { + let broker = broker.clone(); + tokio::spawn(async move { + loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + broker + .complete_call( + &id, + DelegationOutcome::Ok(DelegationSuccess { + text: "result-text".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + }; + + // Drive the listener over a duplex pair. + let (mut client, mut server) = duplex(16 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: "pt-1".into(), + input: json!({"agent_type": "codex", "task": "do x"}), + }; + write_frame(&mut client, &req).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + completer.await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["kind"], "ok"); + assert_eq!(resp.outcome["text"], "result-text"); + assert_eq!(resp.outcome["child_conversation_id"], 42); + } + + #[tokio::test] + async fn token_registry_revoke_and_revoke_by_parent() { + let registry = TokenRegistry::default(); + registry + .register( + "t1".into(), + TokenEntry { + parent_connection_id: "p1".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + registry + .register( + "t2".into(), + TokenEntry { + parent_connection_id: "p1".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + registry + .register( + "t3".into(), + TokenEntry { + parent_connection_id: "p2".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + + registry.revoke("t1").await; + assert!(registry.lookup("t1").await.is_none()); + assert!(registry.lookup("t2").await.is_some()); + + registry.revoke_by_parent("p1").await; + assert!(registry.lookup("t2").await.is_none()); + assert!(registry.lookup("t3").await.is_some()); + } + + // Sanity: spawn failure surfaces as spawn_failed when the listener path + // is exercised. Exercises the full process() → broker.handle_request chain. + #[tokio::test] + async fn spawn_failure_surfaces_through_listener() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("agent missing".into()))).await; + let broker = make_broker(mock); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + default_timeout: Duration::from_secs(5), + }) + .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, tokens, Some(1)); + + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + _ => panic!("expected spawn_failed"), + } + } +} diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index cb980440a..ec8585e49 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -33,6 +33,7 @@ pub mod broker; pub mod companion; pub mod depth; +pub mod listener; pub mod spawner; pub mod transport; pub mod types; diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 90429c046..f4f40e93c 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -18,10 +18,14 @@ use std::time::Duration; use sea_orm::DatabaseConnection; use tokio::sync::{broadcast, mpsc}; +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::types::{ + DelegationError, DelegationOutcome, DelegationSuccess, +}; use crate::acp::internal_bus::InternalEventBus; use crate::acp::manager::ConnectionManager; use crate::acp::session_state::SessionState; -use crate::acp::types::{AcpEvent, ConnectionStatus, EventEnvelope}; +use crate::acp::types::{AcpEvent, ConnectionStatus, DelegationResultSummary, EventEnvelope}; use crate::db::entities::conversation::ConversationStatus; use crate::db::error::DbError; use crate::db::service::conversation_service; @@ -91,8 +95,9 @@ async fn handle_event_with_retry( db_conn: &DatabaseConnection, manager: &ConnectionManager, envelope: &EventEnvelope, + broker: Option<&Arc>, ) { - match handle_event(db_conn, manager, envelope).await { + match handle_event(db_conn, manager, envelope, broker).await { Ok(()) => return, Err(e) => { eprintln!( @@ -103,7 +108,7 @@ async fn handle_event_with_retry( } for (attempt, backoff) in HANDLE_EVENT_RETRY_BACKOFFS.iter().enumerate() { tokio::time::sleep(*backoff).await; - match handle_event(db_conn, manager, envelope).await { + match handle_event(db_conn, manager, envelope, broker).await { Ok(()) => return, Err(e) => { let attempt_num = attempt + 2; @@ -128,6 +133,7 @@ pub(crate) async fn handle_event( db_conn: &DatabaseConnection, manager: &ConnectionManager, envelope: &EventEnvelope, + broker: Option<&Arc>, ) -> Result<(), DbError> { match &envelope.payload { AcpEvent::SessionStarted { session_id } => { @@ -164,38 +170,60 @@ pub(crate) async fn handle_event( // we leave it alone here. `completed` transitions remain // frontend-driven. let target_status = match stop_reason.as_str() { - "end_turn" => ConversationStatus::PendingReview, + "end_turn" => Some(ConversationStatus::PendingReview), "refusal" | "max_tokens" | "max_turn_requests" | "unknown" | "empty" => { - ConversationStatus::Cancelled + Some(ConversationStatus::Cancelled) } // `cancelled` and any future reason: don't write here. - _ => return Ok(()), + _ => None, }; let Some((state_arc, emitter)) = manager.get_state_and_emitter(&envelope.connection_id).await else { return Ok(()); }; - let conversation_id = state_arc.read().await.conversation_id; + let (conversation_id, last_text) = { + let snap = state_arc.read().await; + (snap.conversation_id, snap.last_assistant_text.clone()) + }; // No conversation row bound (defensive — should never happen in // practice since `send_prompt_linked` runs before TurnComplete can // fire). Nothing to update. let Some(cid) = conversation_id else { return Ok(()); }; - // DB write before emit so any downstream subscriber that observes - // the ConversationStatusChanged event can assume the row is - // already at the target status. - conversation_service::update_status(db_conn, cid, target_status.clone()).await?; - emit_with_state( - &state_arc, - &emitter, - AcpEvent::ConversationStatusChanged { - conversation_id: cid, - status: target_status, - }, - ) - .await; + if let Some(ts) = target_status.clone() { + // DB write before emit so any downstream subscriber that observes + // the ConversationStatusChanged event can assume the row is + // already at the target status. + conversation_service::update_status(db_conn, cid, ts.clone()).await?; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::ConversationStatusChanged { + conversation_id: cid, + status: ts, + }, + ) + .await; + } + + // If this conversation was spawned by a delegation, resolve the + // pending broker call. The broker maps the outcome onto the + // parent's `tool_use_id` via the registered `call_id`. + if let Some(b) = broker { + forward_turn_complete_to_broker( + db_conn, + b.as_ref(), + cid, + stop_reason.as_str(), + last_text, + &state_arc, + &emitter, + &envelope.connection_id, + ) + .await; + } Ok(()) } // Other events don't need cross-connection DB persistence today; extend @@ -204,6 +232,95 @@ pub(crate) async fn handle_event( } } +/// On TurnComplete for a delegation child, resolve the pending broker call, +/// emit `DelegationCompleted` for frontend rendering, and let the broker +/// disconnect the child. +#[allow(clippy::too_many_arguments)] +async fn forward_turn_complete_to_broker( + db_conn: &DatabaseConnection, + broker: &DelegationBroker, + conversation_id: i32, + stop_reason: &str, + last_text: Option, + state_arc: &Arc>, + emitter: &EventEmitter, + child_connection_id: &str, +) { + let row = match conversation_service::get_by_id(db_conn, conversation_id).await { + Ok(r) => r, + Err(e) => { + eprintln!( + "[delegation][lifecycle] couldn't fetch child conversation \ + {conversation_id} for outcome routing: {e}" + ); + return; + } + }; + let call_id = match row.delegation_call_id.clone() { + Some(id) => id, + None => return, // not a delegation child; nothing to do. + }; + let parent_tool_use_id = match row.parent_tool_use_id.clone() { + Some(id) => id, + None => { + eprintln!( + "[delegation][lifecycle] conversation {conversation_id} has \ + delegation_call_id but no parent_tool_use_id; dropping" + ); + return; + } + }; + let agent_type = row.agent_type; + let (outcome, result_summary) = match stop_reason { + "end_turn" => ( + DelegationOutcome::Ok(DelegationSuccess { + text: last_text.unwrap_or_default(), + child_conversation_id: conversation_id, + child_agent_type: agent_type, + turn_count: 1, + duration_ms: 0, + token_usage: None, + }), + DelegationResultSummary::Ok { duration_ms: 0 }, + ), + "cancelled" => ( + DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "child session was cancelled".into(), + }, + Some(conversation_id), + ), + DelegationResultSummary::Err { + error_code: "canceled".into(), + }, + ), + other => ( + DelegationOutcome::from_err( + DelegationError::SubagentRuntimeError(format!( + "stop_reason: {other}" + )), + Some(conversation_id), + ), + DelegationResultSummary::Err { + error_code: "subagent_error".into(), + }, + ), + }; + broker.complete_call(&call_id, outcome).await; + emit_with_state( + state_arc, + emitter, + AcpEvent::DelegationCompleted { + parent_connection_id: String::new(), // populated by parent-side listeners using parent_tool_use_id + parent_tool_use_id, + child_connection_id: child_connection_id.to_string(), + child_conversation_id: conversation_id, + result: result_summary, + }, + ) + .await; +} + /// Snapshot the connection's `(state, emitter)` into the lifecycle cache when /// `ConversationLinked` arrives. Idempotent on repeat calls (re-link on the /// already-bound path is a no-op so we don't churn the cached refs). @@ -276,6 +393,16 @@ async fn handle_terminal_event( Ok(()) } +/// On a non-TurnComplete terminal event (Disconnected / Error) for a +/// delegation child, surface a `canceled` outcome to the broker. The +/// child's DB row may already be marked `Cancelled` by `handle_terminal_event` +/// above; this separately wakes the parent's pending `delegate_to_agent` +/// tool_use_id. Match-by-`child_connection_id` is O(pending), bounded by +/// active delegations. +async fn forward_disconnect_to_broker(broker: &DelegationBroker, connection_id: &str) { + broker.cancel_by_child_connection(connection_id).await; +} + /// Per-connection worker that owns the cache for one connection and /// serializes its DB writes. Multiple connections run in parallel; within a /// connection, ordering is preserved by the mpsc FIFO. Decouples the bus @@ -286,6 +413,7 @@ async fn connection_worker_loop( connection_id: String, db: DatabaseConnection, manager: ConnectionManager, + broker: Option>, mut rx: mpsc::Receiver>, ) { // 1-entry HashMap so we can reuse `handle_terminal_event` (also keeps the @@ -306,9 +434,15 @@ async fn connection_worker_loop( if let Err(e) = handle_terminal_event(&db, &mut cache, &connection_id).await { eprintln!("[lifecycle][ERROR] terminal event for {connection_id}: {e}"); } + // If this connection owned a delegation child, surface a + // terminal outcome to the broker so the parent's pending + // tool_use_id doesn't dangle. + if let Some(b) = broker.as_ref() { + forward_disconnect_to_broker(b.as_ref(), &connection_id).await; + } } _ => { - handle_event_with_retry(&db, &manager, envelope).await; + handle_event_with_retry(&db, &manager, envelope, broker.as_ref()).await; } } } @@ -337,6 +471,7 @@ pub fn lifecycle_subscriber_task( db_conn: DatabaseConnection, manager: ConnectionManager, bus: Arc, + broker: Option>, ) -> impl Future + Send + 'static { let mut rx = bus.subscribe(); let metrics = Arc::clone(bus.metrics()); @@ -369,9 +504,10 @@ pub fn lifecycle_subscriber_task( mpsc::channel::>(WORKER_QUEUE_CAPACITY); let db_clone = db_conn.clone(); let mgr_clone = manager.clone_ref(); + let broker_clone = broker.clone(); let id_clone = conn_id.clone(); tokio::spawn(connection_worker_loop( - id_clone, db_clone, mgr_clone, worker_rx, + id_clone, db_clone, mgr_clone, broker_clone, worker_rx, )); tx }); @@ -490,7 +626,7 @@ mod tests { session_id: "ext-99".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); let reloaded = conversation_service::get_by_id(&db.conn, conv.id) .await .unwrap(); @@ -519,7 +655,7 @@ mod tests { session_id: "should-not-write".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); // Sentinel row must still have no external_id — dispatcher correctly // skipped the write because the connection had no conversation_id. @@ -581,7 +717,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::PendingReview @@ -627,7 +763,7 @@ mod tests { agent_type: "open_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::Cancelled, @@ -665,7 +801,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::InProgress, @@ -699,7 +835,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, sentinel.id).await, ConversationStatus::InProgress, @@ -916,7 +1052,7 @@ mod tests { connection_id: "c1".to_string(), payload: AcpEvent::ContentDelta { text: "hi".into() }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); let reloaded = conversation_service::get_by_id(&db.conn, conv.id) .await @@ -1044,6 +1180,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); // Subscribe AFTER spawn would race; the bus's broadcast channel @@ -1107,6 +1244,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); bus.send(Arc::new(EventEnvelope { @@ -1179,6 +1317,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); // Burst of 200 SessionStarted events (each writes external_id). diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index ea988593e..1d046fe78 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -105,6 +105,13 @@ pub struct ConnectionManager { /// tests; in production initialized from env via /// `spawn_handshake_timeout_from_env`. spawn_handshake_timeout: Duration, + /// Delegation broker + token registry + UDS path installed during app + /// bootstrap (`install_delegation`). When present, `spawn_agent` propagates + /// the injection to `spawn_agent_connection`, which makes + /// `codeg-delegate` appear in the agent's MCP server list during ACP + /// init. `Arc` so the inner `Self` cloned from `clone_ref` sees + /// the install too — the lock is set once at startup and never mutated. + delegation_injection: Arc>, } impl Default for ConnectionManager { @@ -119,6 +126,7 @@ impl ConnectionManager { connections: Arc::new(Mutex::new(HashMap::new())), spawn_locks: Arc::new(Mutex::new(HashMap::new())), spawn_handshake_timeout: spawn_handshake_timeout_from_env(), + delegation_injection: Arc::new(std::sync::OnceLock::new()), } } @@ -128,9 +136,24 @@ impl ConnectionManager { connections: self.connections.clone(), spawn_locks: self.spawn_locks.clone(), spawn_handshake_timeout: self.spawn_handshake_timeout, + delegation_injection: self.delegation_injection.clone(), } } + /// Set the delegation injection context exactly once during bootstrap. + /// Calling twice is a no-op — protects against accidental re-init in + /// the unlikely event a second `build_delegation_stack` runs. + pub fn install_delegation( + &self, + injection: crate::acp::connection::DelegationInjection, + ) { + let _ = self.delegation_injection.set(injection); + } + + fn delegation_snapshot(&self) -> Option { + self.delegation_injection.get().cloned() + } + /// Test-only constructor that overrides the spawn-handshake timeout. /// Production code should use `new()`. #[cfg(test)] @@ -139,6 +162,7 @@ impl ConnectionManager { connections: Arc::new(Mutex::new(HashMap::new())), spawn_locks: Arc::new(Mutex::new(HashMap::new())), spawn_handshake_timeout: timeout, + delegation_injection: Arc::new(std::sync::OnceLock::new()), } } @@ -265,6 +289,7 @@ impl ConnectionManager { self.connections.clone(), preferred_mode_id, preferred_config_values, + self.delegation_snapshot(), ) .await?; @@ -583,6 +608,28 @@ impl ConnectionManager { }, ) .await; + + // Surface DelegationStarted on the child's stream so the + // frontend can paint "Delegating to …" against the + // parent's tool_use_id while the child's first turn runs. + // The parent_connection_id isn't on the DelegationLink + // payload — derive it via reverse lookup. (For v1 we leave + // it empty; Phase 8's frontend grouper uses parent_tool_use_id + // as the primary key.) + if let Some(link) = delegation.as_ref() { + emit_with_state( + &state_arc, + &emitter, + AcpEvent::DelegationStarted { + parent_connection_id: String::new(), + parent_tool_use_id: link.parent_tool_use_id.clone(), + child_connection_id: conn_id.to_string(), + child_conversation_id: row.id, + agent_type, + }, + ) + .await; + } } (None, None) => { return Err(AcpError::protocol( @@ -1168,6 +1215,24 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa } } +/// Production impl of `ParentSessionLookup` for the delegation listener. +/// Resolves the parent's current `conversation_id` by reading its +/// `SessionState`. Bundled with `ConnectionManagerSpawner` here so the +/// concrete wiring lives next to the manager it depends on. +#[derive(Clone)] +pub struct ConnectionManagerParentLookup { + pub manager: Arc, +} + +#[async_trait::async_trait] +impl crate::acp::delegation::listener::ParentSessionLookup for ConnectionManagerParentLookup { + async fn current_conversation_id(&self, parent_connection_id: &str) -> Option { + let state = self.manager.get_state(parent_connection_id).await?; + let snapshot = state.read().await; + snapshot.conversation_id + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 1ea1c2b8a..0e3339c4a 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -209,6 +209,18 @@ pub struct SessionState { /// read lock to decide between sending a snapshot or a batched replay. /// See `event_stream` module for size limits. pub(crate) recent_events: RecentEventsBuffer, + + /// Per-launch token registered with the delegation broker's + /// `TokenRegistry` when `codeg-delegate` is injected at init. + /// Revoked when the connection tears down so a leaked binary can't + /// keep round-tripping after the parent session ends. + pub delegation_token: Option, + + /// Concatenated text content of the just-completed turn's assistant + /// message. Captured at TurnComplete (just before live_message is + /// cleared) so the lifecycle subscriber can surface it as the + /// `delegation_call_id`-bound child outcome. Cleared on the next prompt. + pub last_assistant_text: Option, } impl SessionState { @@ -244,6 +256,8 @@ impl SessionState { last_activity_at: Utc::now(), event_stream: Arc::new(ConnectionEventStream::new()), recent_events: RecentEventsBuffer::new(), + delegation_token: None, + last_assistant_text: None, } } @@ -438,6 +452,25 @@ impl SessionState { } } AcpEvent::TurnComplete { .. } => { + // Snapshot the assistant text from the just-finished turn so + // the delegation subscriber can surface it as the child + // outcome. Concatenate all Text blocks in order; skip + // Thinking/ToolCallRef/Plan — they're either non-final or + // structurally separate. + if let Some(live) = self.live_message.as_ref() { + let assembled: String = live + .content + .iter() + .filter_map(|b| match b { + LiveContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + if !assembled.is_empty() { + self.last_assistant_text = Some(assembled); + } + } self.live_message = None; self.active_tool_calls.clear(); self.pending_permission = None; diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 07cd23070..f1234ecb8 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::listener::TokenRegistry; use crate::acp::manager::ConnectionManager; use crate::acp::InternalEventBus; use crate::chat_channel::manager::ChatChannelManager; @@ -30,6 +32,18 @@ pub struct AppState { /// Read by `pet_get_current_state` so a freshly-opened pet window can /// pick up the current state without waiting for the next transition. pub pet_state: PetStateHandle, + /// Multi-agent delegation broker. Spawned in both desktop and server + /// mode at startup; the UDS listener task forwards incoming companion + /// requests here. v1 uses the default `DelegationConfig`; settings UI + /// hot-swaps via `delegation_broker.set_config`. + pub delegation_broker: Arc, + /// Per-launch ephemeral tokens identifying parent ACP connections. + /// Registered when `load_mcp_servers_for_agent` injects the + /// `codeg-delegate` MCP entry, revoked on parent teardown. + pub delegation_tokens: Arc, + /// Absolute path of the UDS / named pipe the companion connects to. + /// PID-scoped so multiple codeg processes on the same host don't fight. + pub delegation_socket_path: PathBuf, } pub fn default_connection_manager() -> ConnectionManager { @@ -44,6 +58,47 @@ pub fn default_chat_channel_manager() -> ChatChannelManager { ChatChannelManager::new() } +/// Build the delegation broker + token registry + per-process UDS socket +/// path. Shared between codeg-server bootstrap and the Tauri `setup` block +/// so both modes apply identical depth limit + timeout defaults. +/// +/// The listener task is _not_ spawned here — callers spawn it after they +/// own an `Arc` (or the relevant pieces) so the listener can +/// borrow the long-lived state without circular Arc shenanigans. +pub fn build_delegation_stack( + connection_manager: &ConnectionManager, + db_conn: sea_orm::DatabaseConnection, +) -> (Arc, Arc, PathBuf) { + use crate::acp::connection::DelegationInjection; + use crate::acp::delegation::broker::{ConversationDepthLookup, DbDepthLookup}; + use crate::acp::delegation::listener::default_socket_path; + use crate::acp::delegation::spawner::ConnectionSpawner; + use crate::acp::manager::ConnectionManagerSpawner; + + let cm_arc = Arc::new(connection_manager.clone_ref()); + let db_arc = Arc::new(AppDatabase { + conn: db_conn.clone(), + }); + let spawner = Arc::new(ConnectionManagerSpawner { + manager: cm_arc, + db: db_arc.clone(), + }) as Arc; + let depth_lookup = Arc::new(DbDepthLookup { db: db_arc }) as Arc; + let broker = Arc::new(DelegationBroker::new(spawner, depth_lookup)); + let tokens = Arc::new(TokenRegistry::default()); + let socket_path = default_socket_path(&std::env::temp_dir()); + + // Install the injection on the manager so spawn_agent picks it up + // without an extra parameter at every call site. + connection_manager.install_delegation(DelegationInjection { + broker: broker.clone(), + tokens: tokens.clone(), + socket_path: socket_path.clone(), + }); + + (broker, tokens, socket_path) +} + impl AppState { /// Test-only constructor: build an `AppState` wired to an in-memory /// database and a `WebOnly` event emitter. Suitable for axum-test driven @@ -61,9 +116,13 @@ impl AppState { let acp_event_bus = Arc::new(InternalEventBus::new(metrics)); let emitter = EventEmitter::web_only(broadcaster.clone(), acp_event_bus.clone()); + let connection_manager = default_connection_manager(); + let (delegation_broker, delegation_tokens, delegation_socket_path) = + build_delegation_stack(&connection_manager, db.conn.clone()); + Self { db, - connection_manager: default_connection_manager(), + connection_manager, terminal_manager: default_terminal_manager(), event_broadcaster: broadcaster, acp_event_bus, @@ -77,6 +136,9 @@ impl AppState { ), ), pet_state: crate::pet_state_mapper::new_pet_state_handle(), + delegation_broker, + delegation_tokens, + delegation_socket_path, } } } diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 4bcb68922..3ac19c780 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -149,9 +149,12 @@ async fn async_main() { // Build AppState let pet_state_handle = codeg_lib::pet_state_mapper::new_pet_state_handle(); + let connection_manager = codeg_lib::app_state::default_connection_manager(); + let (delegation_broker, delegation_tokens, delegation_socket_path) = + codeg_lib::app_state::build_delegation_stack(&connection_manager, db.conn.clone()); let state = Arc::new(AppState { db, - connection_manager: codeg_lib::app_state::default_connection_manager(), + connection_manager, terminal_manager: codeg_lib::app_state::default_terminal_manager(), event_broadcaster: broadcaster, acp_event_bus: acp_event_bus.clone(), @@ -163,8 +166,39 @@ async fn async_main() { codeg_lib::workspace_transfer::WorkspaceTransferManager::new_from_env(), ), pet_state: pet_state_handle.clone(), + delegation_broker: delegation_broker.clone(), + delegation_tokens: delegation_tokens.clone(), + delegation_socket_path: delegation_socket_path.clone(), }); + // Apply persisted delegation settings (depth, timeout, enabled) before + // the listener starts accepting so even the first companion request + // sees the operator's configured behavior. + codeg_lib::commands::delegation::apply_persisted_config( + &state.db.conn, + &delegation_broker, + ) + .await; + + // Spawn the delegation listener so companion processes can round-trip + // through the broker. Path is PID-scoped, so the listener owns it for + // the lifetime of the process. + { + let listener = codeg_lib::acp::delegation::listener::DelegationListener::new( + delegation_broker, + delegation_tokens, + Arc::new(codeg_lib::acp::manager::ConnectionManagerParentLookup { + manager: Arc::new(state.connection_manager.clone_ref()), + }), + ); + let socket = delegation_socket_path.clone(); + tokio::spawn(async move { + if let Err(e) = listener.run(socket).await { + eprintln!("[delegation] listener exited: {e}"); + } + }); + } + // Install bundled expert skills into the central store // (`~/.codeg/skills/`). Runs in the background; failures are logged // but non-fatal. @@ -198,11 +232,15 @@ async fn async_main() { ) .await; - // Spawn the LifecycleSubscriber for cross-connection DB writes. + // Spawn the LifecycleSubscriber for cross-connection DB writes. The + // broker is supplied so TurnComplete on a delegation child resolves the + // parent's pending `delegate_to_agent` tool_use_id and emits + // `DelegationCompleted`. tokio::spawn(codeg_lib::lifecycle_subscriber_task( state.db.conn.clone(), state.connection_manager.clone_ref(), state.acp_event_bus.clone(), + Some(state.delegation_broker.clone()), )); // Spawn the desktop pet state mapper so server-mode browsers viewing diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs new file mode 100644 index 000000000..aef3587ea --- /dev/null +++ b/src-tauri/src/commands/delegation.rs @@ -0,0 +1,269 @@ +//! Delegation settings persistence + Tauri/HTTP command surface. +//! +//! Three knobs survive across restarts: +//! * `delegation.enabled` — feature kill switch (default true) +//! * `delegation.depth_limit` — max chain depth a child is allowed to sit at +//! * `delegation.default_timeout_seconds` — broker fallback when the LLM +//! omits `timeout_seconds` +//! +//! On startup `apply_persisted_config` reads all three keys from +//! `app_metadata` and pushes them into the live `DelegationBroker`. On UI +//! save, `set_delegation_settings_core` writes the three keys and +//! immediately re-applies — the broker has no concept of "pending config", +//! it just owns the current `DelegationConfig`. + +use std::path::PathBuf; +#[cfg(any(test, feature = "tauri-runtime"))] +use std::sync::Arc; +use std::time::Duration; + +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use crate::acp::delegation::broker::{DelegationBroker, DelegationConfig}; +use crate::app_error::AppCommandError; +use crate::db::service::app_metadata_service; + +pub const KEY_DELEGATION_ENABLED: &str = "delegation.enabled"; +pub const KEY_DELEGATION_DEPTH: &str = "delegation.depth_limit"; +pub const KEY_DELEGATION_TIMEOUT: &str = "delegation.default_timeout_seconds"; + +pub const DEPTH_MIN: u32 = 1; +pub const DEPTH_MAX: u32 = 8; +pub const TIMEOUT_MIN_SECS: u64 = 30; +pub const TIMEOUT_MAX_SECS: u64 = 3600; + +/// Newtype so the Tauri managed-state lookup can distinguish the delegation +/// UDS path from other `PathBuf`s in the state graph. +#[derive(Clone)] +pub struct DelegationSocketPath(pub PathBuf); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationSettings { + pub enabled: bool, + pub depth_limit: u32, + pub default_timeout_seconds: u64, +} + +impl Default for DelegationSettings { + fn default() -> Self { + Self { + enabled: true, + depth_limit: 2, + default_timeout_seconds: 600, + } + } +} + +impl DelegationSettings { + fn clamped(self) -> Self { + Self { + enabled: self.enabled, + depth_limit: self.depth_limit.clamp(DEPTH_MIN, DEPTH_MAX), + default_timeout_seconds: self + .default_timeout_seconds + .clamp(TIMEOUT_MIN_SECS, TIMEOUT_MAX_SECS), + } + } + + fn into_broker_config(self) -> DelegationConfig { + DelegationConfig { + enabled: self.enabled, + depth_limit: self.depth_limit, + default_timeout: Duration::from_secs(self.default_timeout_seconds), + } + } +} + +/// Read all three keys from `app_metadata`, falling back to defaults for any +/// missing or malformed value. Never errors hard — corrupt persistence is +/// treated as "no preference yet." +pub async fn load_delegation_settings(conn: &DatabaseConnection) -> DelegationSettings { + let mut settings = DelegationSettings::default(); + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_ENABLED).await { + if let Ok(v) = raw.parse::() { + settings.enabled = v; + } + } + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_DEPTH).await { + if let Ok(v) = raw.parse::() { + settings.depth_limit = v; + } + } + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_TIMEOUT).await { + if let Ok(v) = raw.parse::() { + settings.default_timeout_seconds = v; + } + } + settings.clamped() +} + +/// Pull settings from the DB and push the resulting `DelegationConfig` onto +/// the broker. Idempotent — safe to call on startup, after settings save, or +/// after any external write to `app_metadata`. +pub async fn apply_persisted_config(conn: &DatabaseConnection, broker: &DelegationBroker) { + let settings = load_delegation_settings(conn).await; + broker.set_config(settings.into_broker_config()).await; +} + +/// Persist + apply. Used by both the Tauri command and the HTTP handler so +/// the clamp / re-apply chain is in exactly one place. +pub async fn set_delegation_settings_core( + conn: &DatabaseConnection, + broker: &DelegationBroker, + desired: DelegationSettings, +) -> Result { + let clamped = desired.clamped(); + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_ENABLED, + &clamped.enabled.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_DEPTH, + &clamped.depth_limit.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_TIMEOUT, + &clamped.default_timeout_seconds.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + broker.set_config(clamped.clone().into_broker_config()).await; + Ok(clamped) +} + +// -------- Tauri commands ----------------------------------------------------- + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_delegation_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + Ok(load_delegation_settings(&db.conn).await) + } + #[cfg(not(feature = "tauri-runtime"))] + { + // Server mode reaches this via the web handler, not this command. + Err(AppCommandError::configuration_invalid( + "tauri-only command", + )) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn set_delegation_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] broker: tauri::State<'_, Arc>, + settings: DelegationSettings, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + set_delegation_settings_core(&db.conn, broker.inner(), settings).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = settings; + Err(AppCommandError::configuration_invalid( + "tauri-only command", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationBroker}; + use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner}; + use crate::acp::delegation::types::DelegationError; + use async_trait::async_trait; + + struct EmptyLookup; + #[async_trait] + impl ConversationDepthLookup for EmptyLookup { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } + } + + fn make_broker() -> DelegationBroker { + DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(EmptyLookup) as Arc, + ) + } + + #[test] + fn settings_clamp_to_safe_range() { + let s = DelegationSettings { + enabled: true, + depth_limit: 99, + default_timeout_seconds: 10, + } + .clamped(); + assert_eq!(s.depth_limit, DEPTH_MAX); + assert_eq!(s.default_timeout_seconds, TIMEOUT_MIN_SECS); + } + + #[tokio::test] + async fn load_returns_defaults_when_unset() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let settings = load_delegation_settings(&db.conn).await; + assert!(settings.enabled); + assert_eq!(settings.depth_limit, 2); + assert_eq!(settings.default_timeout_seconds, 600); + } + + #[tokio::test] + async fn set_then_load_round_trip_and_broker_applied() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let broker = make_broker(); + let desired = DelegationSettings { + enabled: false, + depth_limit: 3, + default_timeout_seconds: 120, + }; + let saved = set_delegation_settings_core(&db.conn, &broker, desired) + .await + .unwrap(); + assert!(!saved.enabled); + assert_eq!(saved.depth_limit, 3); + assert_eq!(saved.default_timeout_seconds, 120); + + let loaded = load_delegation_settings(&db.conn).await; + assert_eq!(loaded.enabled, saved.enabled); + assert_eq!(loaded.depth_limit, saved.depth_limit); + assert_eq!(loaded.default_timeout_seconds, saved.default_timeout_seconds); + + let cfg = broker.config_snapshot().await; + assert!(!cfg.enabled); + assert_eq!(cfg.depth_limit, 3); + assert_eq!(cfg.default_timeout, Duration::from_secs(120)); + } + + #[tokio::test] + async fn set_clamps_out_of_range_values() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let broker = make_broker(); + let saved = set_delegation_settings_core( + &db.conn, + &broker, + DelegationSettings { + enabled: true, + depth_limit: 999, + default_timeout_seconds: 10, + }, + ) + .await + .unwrap(); + assert_eq!(saved.depth_limit, DEPTH_MAX); + assert_eq!(saved.default_timeout_seconds, TIMEOUT_MIN_SECS); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e04dd0254..8c4c93607 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod acp; pub mod chat_channel; pub mod conversations; +pub mod delegation; pub mod experts; #[cfg(feature = "tauri-runtime")] pub mod file_io; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 570c77103..bf319b29e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,11 +41,11 @@ mod tauri_app { use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ acp as acp_commands, chat_channel as chat_channel_commands, conversations, - experts as experts_commands, file_io, folder_commands, folders, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, - quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, system_settings, - terminal as terminal_commands, version_control, windows, + delegation as delegation_commands, experts as experts_commands, file_io, folder_commands, + folders, mcp as mcp_commands, model_provider as model_provider_commands, notification, + pet as pet_commands, project_boot, quick_messages as quick_messages_commands, + remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, + system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; @@ -363,6 +363,52 @@ mod tauri_app { ); } + // Delegation broker + UDS listener. Built from the managed + // ConnectionManager + DB so spawn / depth-lookup work against + // live state. Managed alongside the existing per-resource + // states so commands (Tauri + web) can resolve them by type. + // MUST run before the LifecycleSubscriber spawn below so the + // broker handle is available to it. + let broker_for_lifecycle = { + let cm_state = app.state::(); + let db_conn = app.state::().conn.clone(); + let (broker, tokens, socket_path) = + crate::app_state::build_delegation_stack(&cm_state, db_conn.clone()); + app.manage(broker.clone()); + app.manage(tokens.clone()); + app.manage(crate::commands::delegation::DelegationSocketPath( + socket_path.clone(), + )); + + // Push persisted settings into the broker before listener accept. + let broker_for_init = broker.clone(); + let db_for_init = db_conn.clone(); + tauri::async_runtime::block_on(async move { + delegation_commands::apply_persisted_config( + &db_for_init, + &broker_for_init, + ) + .await; + }); + + let listener_broker = broker.clone(); + let listener = crate::acp::delegation::listener::DelegationListener::new( + listener_broker, + tokens, + std::sync::Arc::new( + crate::acp::manager::ConnectionManagerParentLookup { + manager: std::sync::Arc::new(cm_state.clone_ref()), + }, + ), + ); + tauri::async_runtime::spawn(async move { + if let Err(e) = listener.run(socket_path).await { + eprintln!("[delegation] listener exited: {e}"); + } + }); + broker + }; + // Spawn the LifecycleSubscriber: persists cross-connection DB state // (currently `external_id` on conversation rows when SessionStarted fires) // off the emit hot path. `subscribe()` runs synchronously inside @@ -377,7 +423,10 @@ mod tauri_app { .inner() .clone(); tauri::async_runtime::spawn(crate::acp::lifecycle_subscriber_task( - db_conn, cm, bus, + db_conn, + cm, + bus, + Some(broker_for_lifecycle), )); } @@ -754,6 +803,8 @@ mod tauri_app { system_settings::probe_terminal_shell_path, system_settings::get_system_rendering_settings, system_settings::update_system_rendering_settings, + delegation_commands::get_delegation_settings, + delegation_commands::set_delegation_settings, version_control::detect_git, version_control::test_git_path, version_control::get_git_settings, diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index c24bb6197..eea0c15b8 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -652,6 +652,21 @@ pub(crate) async fn do_start_web_server_tauri( .state::() .inner() .clone(), + // Reuse the live broker / token registry / socket path from the + // Tauri-managed state so HTTP-side delegation commands target the + // same listener the desktop process is already running. + delegation_broker: app + .state::>() + .inner() + .clone(), + delegation_tokens: app + .state::>() + .inner() + .clone(), + delegation_socket_path: app + .state::() + .0 + .clone(), }); // See do_start_web_server_with_state for rationale on the reset. diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs new file mode 100644 index 000000000..10589bf24 --- /dev/null +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -0,0 +1,184 @@ +//! End-to-end Phase 6 integration: drive a real UDS round-trip from a +//! companion-style client through the listener → broker → mock spawner → +//! `complete_call`, and assert the outcome is delivered to the wire. +//! +//! Skipped on non-unix targets (named-pipe windows path tested separately). + +#![cfg(unix)] + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use codeg_lib::acp::delegation::broker::{ + ConversationDepthLookup, DelegationBroker, DelegationConfig, +}; +use codeg_lib::acp::delegation::listener::{ + DelegationListener, ParentSessionLookup, TokenEntry, TokenRegistry, +}; +use codeg_lib::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner}; +use codeg_lib::acp::delegation::transport::{client_round_trip, BrokerRequest}; +use codeg_lib::acp::delegation::types::{ + DelegationError, DelegationOutcome, DelegationSuccess, +}; +use codeg_lib::models::AgentType; +use serde_json::json; + +struct AlwaysRoot; +#[async_trait] +impl ConversationDepthLookup for AlwaysRoot { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } +} + +struct FixedParent(i32); +#[async_trait] +impl ParentSessionLookup for FixedParent { + async fn current_conversation_id(&self, _: &str) -> Option { + Some(self.0) + } +} + +#[tokio::test] +async fn end_to_end_uds_happy_path() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(77)).await; + + let broker = Arc::new(DelegationBroker::new( + mock.clone() as Arc, + Arc::new(AlwaysRoot) as Arc, + )); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + default_timeout: Duration::from_secs(30), + }) + .await; + + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "p1".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + + let listener = DelegationListener::new( + broker.clone(), + tokens, + Arc::new(FixedParent(1)) as Arc, + ); + + // PID-scoped socket inside the OS temp dir — no clashes across test bins. + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("codeg-e2e.sock"); + let socket_for_listener = socket.clone(); + let listener_task = tokio::spawn(async move { + let _ = listener.run(socket_for_listener).await; + }); + + // Spin until the socket is bound and ready to accept. + for _ in 0..50 { + if socket.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(socket.exists(), "listener never bound the socket"); + + // Drive completion from a parallel task so the client send→recv races + // against the broker registration. + let broker_for_completion = broker.clone(); + let completer = tokio::spawn(async move { + loop { + if let Some(call_id) = broker_for_completion + .peek_first_pending_call_id() + .await + { + broker_for_completion + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "uds-result".into(), + child_conversation_id: 77, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 12, + token_usage: None, + }), + ) + .await; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt-1".into(), + input: json!({"agent_type": "codex", "task": "do x"}), + }; + let resp = client_round_trip(&socket.to_string_lossy(), &req) + .await + .expect("client round-trip"); + + completer.await.unwrap(); + listener_task.abort(); + + assert_eq!(resp.outcome["kind"], "ok"); + assert_eq!(resp.outcome["text"], "uds-result"); + assert_eq!(resp.outcome["child_conversation_id"], 77); +} + +#[tokio::test] +async fn end_to_end_uds_invalid_token_rejected() { + let mock = Arc::new(MockSpawner::new()); + // No queued spawn — listener should reject before reaching broker. + let broker = Arc::new(DelegationBroker::new( + mock as Arc, + Arc::new(AlwaysRoot) as Arc, + )); + let tokens = Arc::new(TokenRegistry::default()); + let listener = DelegationListener::new( + broker, + tokens, + Arc::new(FixedParent(1)) as Arc, + ); + + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("codeg-e2e-reject.sock"); + let socket_for_listener = socket.clone(); + let listener_task = tokio::spawn(async move { + let _ = listener.run(socket_for_listener).await; + }); + + for _ in 0..50 { + if socket.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let req = BrokerRequest { + token: "wrong-token".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt-1".into(), + input: json!({"agent_type": "codex", "task": "x"}), + }; + let resp = client_round_trip(&socket.to_string_lossy(), &req) + .await + .expect("client round-trip"); + listener_task.abort(); + + assert_eq!(resp.outcome["kind"], "err"); + assert_eq!(resp.outcome["code"], "canceled"); +} From 5f39ce38f37f31b24bf5c9d0a067edf4e2443b35 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 05:14:20 +0800 Subject: [PATCH 07/29] feat(conversations): include_children filter + list_child_conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegation sub-sessions live in the DB as conversations with parent_id set. Without a filter they'd appear as top-level rows in the workspace list, duplicating what their parent's ToolCallBlock will render inline (Phase 8). * `list_all` gains include_children: bool — defaults to false so the workspace list automatically hides delegation children. * New `list_children(parent_id)` service + `list_child_conversations` Tauri command / HTTP handler / route, oldest-first, soft-delete aware. * DbConversationSummary mirror adds parent_id / parent_tool_use_id / delegation_call_id so the frontend can render the parent ↔ child link. * api.ts + tauri.ts: listAllConversations gains includeChildren param, new listChildConversations(parentId). * 4 service tests + 2 command tests cover default-exclude, opt-in include, child-scoping, and soft-delete invisibility. Frontend UI toggle deferred to Phase 8 where the inline child rendering under the parent's ToolCallBlock lands — separate top-level toggle would just be developer scaffolding until that view exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/conversations.rs | 109 +++++++++++++++- .../src/db/service/conversation_service.rs | 120 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/conversations.rs | 21 +++ src-tauri/src/web/router.rs | 4 + src/lib/api.ts | 10 ++ src/lib/tauri.ts | 10 ++ src/lib/types.ts | 3 + 8 files changed, 272 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index d9249fa9f..99736bd5a 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -21,10 +21,19 @@ pub async fn list_all_conversations_core( search: Option, sort_by: Option, status: Option, + include_children: bool, ) -> Result, AppCommandError> { - conversation_service::list_all(conn, folder_ids, agent_type, search, sort_by, status) - .await - .map_err(AppCommandError::from) + conversation_service::list_all( + conn, + folder_ids, + agent_type, + search, + sort_by, + status, + include_children, + ) + .await + .map_err(AppCommandError::from) } #[cfg(feature = "tauri-runtime")] @@ -36,8 +45,36 @@ pub async fn list_all_conversations( search: Option, sort_by: Option, status: Option, + include_children: Option, ) -> Result, AppCommandError> { - list_all_conversations_core(&db.conn, folder_ids, agent_type, search, sort_by, status).await + list_all_conversations_core( + &db.conn, + folder_ids, + agent_type, + search, + sort_by, + status, + include_children.unwrap_or(false), + ) + .await +} + +pub async fn list_child_conversations_core( + conn: &sea_orm::DatabaseConnection, + parent_conversation_id: i32, +) -> Result, AppCommandError> { + conversation_service::list_children(conn, parent_conversation_id) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_child_conversations( + db: tauri::State<'_, AppDatabase>, + parent_conversation_id: i32, +) -> Result, AppCommandError> { + list_child_conversations_core(&db.conn, parent_conversation_id).await } pub async fn list_opened_tabs_core( @@ -665,7 +702,7 @@ mod tests { #[tokio::test] async fn list_all_conversations_core_empty_db_returns_empty() { let db = fresh_in_memory_db().await; - let rows = list_all_conversations_core(&db.conn, None, None, None, None, None) + let rows = list_all_conversations_core(&db.conn, None, None, None, None, None, false) .await .expect("list"); assert!(rows.is_empty(), "fresh db must have zero conversations"); @@ -770,7 +807,7 @@ mod tests { .await .expect("delete"); // After soft delete the row should no longer show up in list_all. - let remaining = list_all_conversations_core(&db.conn, None, None, None, None, None) + let remaining = list_all_conversations_core(&db.conn, None, None, None, None, None, false) .await .expect("list"); assert!( @@ -778,4 +815,64 @@ mod tests { "soft-deleted conversation must not appear in list_all" ); } + + // ────────────────────────────────────────────────────────────────────── + // Phase 7 — delegation list filter + child lookup wrappers. + // ────────────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn list_child_conversations_core_returns_empty_for_no_parent() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-list-children-empty").await; + let parent_id = create_conversation_core(&db.conn, folder_id, AgentType::Codex, None) + .await + .expect("create parent"); + let rows = list_child_conversations_core(&db.conn, parent_id) + .await + .expect("list"); + assert!(rows.is_empty()); + } + + #[tokio::test] + async fn list_child_conversations_core_returns_only_matching_children() { + use crate::acp::delegation::spawner::DelegationLink; + use crate::db::service::conversation_service; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-list-children-match").await; + let parent_id = create_conversation_core(&db.conn, folder_id, AgentType::ClaudeCode, None) + .await + .expect("create parent"); + + // Two delegation children — both should come back, oldest-first. + for (i, tool_use) in ["tu-A", "tu-B"].iter().enumerate() { + let link = DelegationLink { + parent_conversation_id: parent_id, + parent_tool_use_id: (*tool_use).into(), + delegation_call_id: format!("call-{i}"), + }; + conversation_service::create_with_delegation( + &db.conn, + folder_id, + AgentType::Codex, + Some(format!("child-{i}")), + None, + Some(link), + ) + .await + .expect("create child"); + } + // Sibling root conversation that must NOT appear. + let _other = create_conversation_core(&db.conn, folder_id, AgentType::Gemini, None) + .await + .expect("unrelated root"); + + let rows = list_child_conversations_core(&db.conn, parent_id) + .await + .expect("list"); + assert_eq!(rows.len(), 2, "expected 2 children, got {}", rows.len()); + assert!(rows.iter().all(|r| r.parent_id == Some(parent_id))); + // Oldest-first ordering (created_at ascending). + assert!(rows[0].created_at <= rows[1].created_at); + } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 093f96312..f3a5df234 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -250,6 +250,11 @@ pub async fn list_by_folder( /// List conversations across folders. When `folder_ids` is `None`, queries all /// When `folder_ids` is provided, results are scoped to that set. Otherwise /// returns conversations across every non-deleted folder (open or not). +/// +/// `include_children` controls visibility of delegation sub-sessions. When +/// `false` (the default for the top-level list), rows whose `parent_id` is +/// non-null are filtered out — they belong to their parent's tool-call view, +/// not the workspace conversation list. pub async fn list_all( conn: &DatabaseConnection, folder_ids: Option>, @@ -257,9 +262,14 @@ pub async fn list_all( search: Option, sort_by: Option, status: Option, + include_children: bool, ) -> Result, DbError> { let mut query = conversation::Entity::find().filter(conversation::Column::DeletedAt.is_null()); + if !include_children { + query = query.filter(conversation::Column::ParentId.is_null()); + } + match folder_ids { Some(ids) if !ids.is_empty() => { query = query.filter(conversation::Column::FolderId.is_in(ids)); @@ -310,3 +320,113 @@ pub async fn list_all( let rows = query.all(conn).await?; Ok(rows.into_iter().map(conv_to_summary).collect()) } + +/// List delegation children of a single parent conversation, oldest first. +/// Returns rows where `parent_id == parent_conversation_id`. Soft-deleted +/// children are filtered out so a removed sub-session stays hidden in the +/// parent's tool-call view too. +pub async fn list_children( + conn: &DatabaseConnection, + parent_conversation_id: i32, +) -> Result, DbError> { + let rows = conversation::Entity::find() + .filter(conversation::Column::ParentId.eq(parent_conversation_id)) + .filter(conversation::Column::DeletedAt.is_null()) + .order_by_asc(conversation::Column::CreatedAt) + .all(conn) + .await?; + Ok(rows.into_iter().map(conv_to_summary).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::DelegationLink; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + /// Build a parent + a delegation child for filter assertions. + async fn seed_parent_with_child( + conn: &DatabaseConnection, + folder_id: i32, + ) -> (i32, i32) { + let parent = create(conn, folder_id, AgentType::ClaudeCode, Some("P".into()), None) + .await + .expect("parent"); + let link = DelegationLink { + parent_conversation_id: parent.id, + parent_tool_use_id: "tu-1".into(), + delegation_call_id: "call-1".into(), + }; + let child = create_with_delegation( + conn, + folder_id, + AgentType::Codex, + Some("C".into()), + None, + Some(link), + ) + .await + .expect("child"); + (parent.id, child.id) + } + + #[tokio::test] + async fn list_all_excludes_children_by_default() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-default").await; + let (parent, _child) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_all(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!(ids.contains(&parent), "parent must remain visible: {ids:?}"); + assert_eq!( + rows.len(), + 1, + "expected only the parent, got {} rows: {ids:?}", + rows.len() + ); + } + + #[tokio::test] + async fn list_all_includes_children_when_requested() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-on").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_all(&db.conn, None, None, None, None, None, true) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!( + ids.contains(&parent) && ids.contains(&child), + "both parent + child must appear when include_children=true, got: {ids:?}", + ); + } + + #[tokio::test] + async fn list_children_returns_only_matching_parent() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-only").await; + let (parent_a, child_a) = seed_parent_with_child(&db.conn, folder).await; + let (_parent_b, _child_b) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_children(&db.conn, parent_a).await.expect("list"); + assert_eq!(rows.len(), 1, "expected 1 child of parent_a, got {}", rows.len()); + assert_eq!(rows[0].id, child_a); + assert_eq!(rows[0].parent_id, Some(parent_a)); + } + + #[tokio::test] + async fn list_children_excludes_soft_deleted() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-soft-del").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + soft_delete(&db.conn, child).await.expect("soft delete"); + + let rows = list_children(&db.conn, parent).await.expect("list"); + assert!(rows.is_empty(), "soft-deleted child must not appear: {rows:?}"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf319b29e..1803e560a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -656,6 +656,7 @@ mod tauri_app { conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, + conversations::list_child_conversations, conversations::list_opened_tabs, conversations::save_opened_tabs, conversations::import_local_conversations, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 38b530794..f750e8b5d 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -16,6 +16,7 @@ pub struct ListAllConversationsParams { pub search: Option, pub sort_by: Option, pub status: Option, + pub include_children: Option, } pub async fn list_all_conversations( @@ -30,6 +31,26 @@ pub async fn list_all_conversations( params.search, params.sort_by, params.status, + params.include_children.unwrap_or(false), + ) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListChildConversationsParams { + pub parent_conversation_id: i32, +} + +pub async fn list_child_conversations( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + conv_commands::list_child_conversations_core( + &state.db.conn, + params.parent_conversation_id, ) .await?, )) diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 1df07906c..af280a951 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -52,6 +52,10 @@ pub fn build_router( "/list_all_conversations", post(handlers::conversations::list_all_conversations), ) + .route( + "/list_child_conversations", + post(handlers::conversations::list_child_conversations), + ) .route( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), diff --git a/src/lib/api.ts b/src/lib/api.ts index 276133f35..9ba9b27b6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -683,6 +683,7 @@ export async function listAllConversations(params?: { search?: string | null sort_by?: string | null status?: string | null + include_children?: boolean | null }): Promise { return getTransport().call("list_all_conversations", { folderIds: params?.folder_ids ?? null, @@ -690,6 +691,15 @@ export async function listAllConversations(params?: { search: params?.search ?? null, sortBy: params?.sort_by ?? null, status: params?.status ?? null, + includeChildren: params?.include_children ?? null, + }) +} + +export async function listChildConversations( + parentConversationId: number +): Promise { + return getTransport().call("list_child_conversations", { + parentConversationId, }) } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 1dd628eed..4271b6b6d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -516,6 +516,7 @@ export async function listAllConversations(params?: { search?: string | null sort_by?: string | null status?: string | null + include_children?: boolean | null }): Promise { return invoke("list_all_conversations", { folderIds: params?.folder_ids ?? null, @@ -523,6 +524,15 @@ export async function listAllConversations(params?: { search: params?.search ?? null, sortBy: params?.sort_by ?? null, status: params?.status ?? null, + includeChildren: params?.include_children ?? null, + }) +} + +export async function listChildConversations( + parentConversationId: number +): Promise { + return invoke("list_child_conversations", { + parentConversationId, }) } diff --git a/src/lib/types.ts b/src/lib/types.ts index abbc3a1ee..bd42c19cb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -248,6 +248,9 @@ export interface DbConversationSummary { message_count: number created_at: string updated_at: string + parent_id?: number | null + parent_tool_use_id?: string | null + delegation_call_id?: string | null } export interface ImportResult { From 2b1811b19f8472af5b7a8ffabb4dd84fe963e0bd Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 05:57:13 +0800 Subject: [PATCH 08/29] feat(ui): inline DelegatedSubThread + delegation event binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the parent ↔ child delegation rendering for `delegate_to_agent` ToolCallBlocks. The wire events fire on the child's connection stream, so the parent's UI needs a global subscription to resolve a binding by parent_tool_use_id — that's the new DelegationContext. * AcpEvent mirror gains `delegation_started` / `delegation_completed` (+ DelegationResultSummary discriminated by `kind`). * DelegationProvider — one global `acp://event` subscription, builds Map, status transitions running → ok/err. Mounted between AcpConnectionsProvider and the ConversationRuntimeProvider so the binding outlives a single chat tab. * useDelegatedSubSession — resolves the binding + fetches the child conversation detail lazily (only on expand). Internal useReducer state machine to keep `react-hooks/set-state-in-effect` happy. * DelegatedSubThread — collapsed header (agent label + status badge + last assistant text snippet) and expand-to-preview body listing the child's turns. Status badge uses static error-code keys; the Rust DelegationError taxonomy maps 1:1 to the next-intl keys. * content-parts-renderer routes `delegate_to_agent` to the new component when the tool-call has a toolCallId; otherwise falls through. * i18n: en + zh-CN + zh-TW fully translated; ar/de/es/fr/ja/ko/pt get English placeholders for parity (Phase 9 localizes properly). * Test: vitest covers the four user-visible states (no binding, running, error code, expand-on-click). Deferred from plan Task 8.3 / 8.7: * Permission inline routing — the existing per-connection permission store needs broader plumbing to surface child-permission requests on the parent ToolCallBlock. Tackle in Phase 9. * `ToolCallState.meta["codeg.delegation"]` live state — the backend doesn't yet stamp delegation markers on tool-call meta, so the pre-binding "Delegating to X…" status has no source field. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/workspace/layout.tsx | 43 +-- .../message/content-parts-renderer.tsx | 9 + .../message/delegated-sub-thread.test.tsx | 121 ++++++++ .../message/delegated-sub-thread.tsx | 258 ++++++++++++++++++ src/contexts/delegation-context.tsx | 162 +++++++++++ src/hooks/use-delegated-sub-session.ts | 98 +++++++ src/i18n/messages/ar.json | 20 ++ src/i18n/messages/de.json | 20 ++ src/i18n/messages/en.json | 20 ++ src/i18n/messages/es.json | 20 ++ src/i18n/messages/fr.json | 20 ++ src/i18n/messages/ja.json | 20 ++ src/i18n/messages/ko.json | 20 ++ src/i18n/messages/pt.json | 20 ++ src/i18n/messages/zh-CN.json | 20 ++ src/i18n/messages/zh-TW.json | 20 ++ src/lib/types.ts | 36 +++ 17 files changed, 908 insertions(+), 19 deletions(-) create mode 100644 src/components/message/delegated-sub-thread.test.tsx create mode 100644 src/components/message/delegated-sub-thread.tsx create mode 100644 src/contexts/delegation-context.tsx create mode 100644 src/hooks/use-delegated-sub-session.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 7febb7a9d..522f14336 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -26,6 +26,7 @@ import { AcpConnectionsProvider, useAcpActions, } from "@/contexts/acp-connections-context" +import { DelegationProvider } from "@/contexts/delegation-context" import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context" import { TabProvider, useTabContext } from "@/contexts/tab-context" import { SessionStatsProvider } from "@/contexts/session-stats-context" @@ -775,25 +776,29 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { - - - - - - - - - - - - {children} - - - - - - - + + + + + + + + + + + + + + {children} + + + + + + + + + diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 07785da33..4c50d99d7 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -37,6 +37,7 @@ import { ReasoningContent, } from "@/components/ai-elements/reasoning" import { AgentToolCallPart } from "./agent-tool-call" +import { DelegatedSubThread } from "./delegated-sub-thread" import { GeneratedImagesBlock } from "./generated-images-block" import { FileTextIcon, @@ -2292,6 +2293,14 @@ const ToolCallPart = memo(function ToolCallPart({ ) } + // Multi-agent delegation tool: surfaces an inline DelegatedSubThread + // bound to the child sub-session via parent_tool_use_id. Falls through to + // the normal renderer when no toolCallId is available (snapshot replays + // without a live binding) so the user still sees the tool input/output. + if (toolNameLower === "delegate_to_agent" && part.toolCallId) { + return + } + // Cline: attempt_completion — render as an expanded card with result + progress if (toolNameLower === "attempt_completion") { const parsedCompletion = tryParseJson(part.input ?? "") diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx new file mode 100644 index 000000000..fa867fedb --- /dev/null +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +import { DelegatedSubThread } from "./delegated-sub-thread" +import enMessages from "@/i18n/messages/en.json" +import type { DelegationBinding } from "@/contexts/delegation-context" + +vi.mock("@/hooks/use-delegated-sub-session", () => ({ + useDelegatedSubSession: vi.fn(), +})) + +const { useDelegatedSubSession } = await import( + "@/hooks/use-delegated-sub-session" +) +const mockedHook = vi.mocked(useDelegatedSubSession) + +function renderWithIntl(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +function bindingOf(overrides: Partial): DelegationBinding { + return { + parentConnectionId: "p1", + parentToolUseId: "pt-1", + childConnectionId: "c1", + childConversationId: 99, + agentType: "codex", + status: "running", + ...overrides, + } +} + +describe("DelegatedSubThread", () => { + it("renders nothing when no binding exists yet", () => { + mockedHook.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + const { container } = renderWithIntl( + + ) + expect(container.firstChild).toBeNull() + }) + + it("renders agent label + running badge when delegation is in-flight", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "running" }), + detail: null, + loading: false, + error: null, + }) + renderWithIntl() + expect(screen.getByText("Codex")).toBeInTheDocument() + expect(screen.getByText("running")).toBeInTheDocument() + // collapsed by default — sub-thread body not present + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + }) + + it("shows the error badge with the localized code", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "err", errorCode: "timeout" }), + detail: null, + loading: false, + error: null, + }) + renderWithIntl() + expect(screen.getByText("timeout")).toBeInTheDocument() + }) + + it("toggles the body open and shows the last assistant text as summary", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "ok" }), + detail: { + summary: { + id: 99, + folder_id: 1, + title: null, + agent_type: "codex", + status: "completed", + model: null, + git_branch: null, + external_id: null, + message_count: 1, + created_at: "2026-05-23T00:00:00Z", + updated_at: "2026-05-23T00:00:00Z", + }, + turns: [ + { + id: "u1", + role: "user", + blocks: [{ type: "text", text: "do something" }], + timestamp: "2026-05-23T00:00:00Z", + }, + { + id: "a1", + role: "assistant", + blocks: [{ type: "text", text: "delegated answer body" }], + timestamp: "2026-05-23T00:00:05Z", + }, + ], + }, + loading: false, + error: null, + }) + renderWithIntl() + // Summary line in the header shows the assistant's last text. + expect(screen.getByText("delegated answer body")).toBeInTheDocument() + const toggle = screen.getByRole("button") + fireEvent.click(toggle) + // Once expanded the sub-thread renders the role label. + expect(screen.getByText("Assistant")).toBeInTheDocument() + expect(screen.getByText("User")).toBeInTheDocument() + }) +}) diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx new file mode 100644 index 000000000..28d0fc8a1 --- /dev/null +++ b/src/components/message/delegated-sub-thread.tsx @@ -0,0 +1,258 @@ +"use client" + +/** + * Inline rendering of a delegated child sub-session under the parent's + * `delegate_to_agent` ToolCallBlock. Default state is a single-row header + * (agent type + status badge + last-message snippet); clicking the chevron + * expands a scrollable preview of the child's turns. + * + * Scope intentionally lean for Phase 8: + * * Only `text` and `thinking` content blocks are rendered in the + * preview body — tool_use / tool_result / image are summarized as a + * compact "tool" line. Phase 9 may upgrade to the full + * content-parts-renderer if the user-facing value justifies the size. + * * No virtualization — typical delegated sessions are small (≤ ~20 + * turns); if a user delegates a long-running task, the parent block + * stays scrollable and the user can navigate to the child conversation + * directly. + * * `loading` is only shown for the first fetch. The binding's status + * transition (running → ok/err) does NOT trigger a re-fetch — callers + * who want the latest turns can collapse + re-expand. + */ + +import { useState } from "react" +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" + +import { useDelegatedSubSession } from "@/hooks/use-delegated-sub-session" +import { + AGENT_COLORS, + AGENT_LABELS, + type ContentBlock, + type MessageTurn, +} from "@/lib/types" +import { cn } from "@/lib/utils" + +interface Props { + parentToolUseId: string +} + +function blocksToText(blocks: ContentBlock[]): string { + for (const b of blocks) { + if (b.type === "text" && b.text.trim().length > 0) return b.text + if (b.type === "thinking" && b.text.trim().length > 0) return b.text + } + return "" +} + +function turnSummary(turns: MessageTurn[] | undefined): string { + if (!turns || turns.length === 0) return "" + // Walk back to find the most recent assistant turn with substantive text; + // fall back to the last turn's text if every assistant turn was tool-only. + for (let i = turns.length - 1; i >= 0; i--) { + if (turns[i].role !== "assistant") continue + const text = blocksToText(turns[i].blocks) + if (text) return text + } + return blocksToText(turns[turns.length - 1].blocks) +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return s.slice(0, max).trimEnd() + "…" +} + +export function DelegatedSubThread({ parentToolUseId }: Props) { + const t = useTranslations("Folder.chat.delegation") + const [expanded, setExpanded] = useState(false) + const { binding, detail, loading, error } = useDelegatedSubSession( + parentToolUseId, + { enabled: expanded } + ) + + if (!binding) { + return null + } + + const summary = truncate(turnSummary(detail?.turns), 120) + const turnCount = detail?.turns?.length ?? 0 + + return ( +
+ + {expanded && ( +
+ {loading && ( +
+ + {t("loading")} +
+ )} + {error && ( +
+ {t("loadFailed", { detail: error })} +
+ )} + {!loading && !error && detail && ( + + )} + {!loading && !error && !detail && ( +
{t("noDetail")}
+ )} +
+ )} +
+ ) +} + +function StatusBadge({ + status, + errorCode, +}: { + status: "running" | "ok" | "err" + errorCode?: string +}) { + // next-intl's template-literal-typed t() blows up on dynamic keys, so + // every label is fetched with a static key string. The known error codes + // mirror the Rust `DelegationError` taxonomy. + const t = useTranslations("Folder.chat.delegation.status") + if (status === "running") { + return ( + + + {t("running")} + + ) + } + if (status === "ok") { + return ( + + {t("ok")} + + ) + } + return ( + + + + ) +} + +function ErrorLabel({ code }: { code?: string }) { + const t = useTranslations("Folder.chat.delegation.status.err") + switch (code) { + case "delegation_disabled": + return <>{t("delegation_disabled")} + case "depth_limit": + return <>{t("depth_limit")} + case "invalid_agent_type": + return <>{t("invalid_agent_type")} + case "spawn_failed": + return <>{t("spawn_failed")} + case "send_failed": + return <>{t("send_failed")} + case "timeout": + return <>{t("timeout")} + case "canceled": + return <>{t("canceled")} + default: + return <>{t("default")} + } +} + +function SubThreadPreview({ turns }: { turns: MessageTurn[] }) { + if (turns.length === 0) { + return — no messages yet — + } + return ( +
+ {turns.map((turn) => ( + + ))} +
+ ) +} + +function TurnRow({ turn }: { turn: MessageTurn }) { + const roleLabel = + turn.role === "user" + ? "User" + : turn.role === "assistant" + ? "Assistant" + : "System" + return ( +
+
+ {roleLabel} +
+ {turn.blocks.map((b, i) => ( + + ))} +
+ ) +} + +function BlockLine({ block }: { block: ContentBlock }) { + if (block.type === "text") { + return ( +
{block.text}
+ ) + } + if (block.type === "thinking") { + return ( +
+ {block.text} +
+ ) + } + if (block.type === "tool_use") { + return ( +
+ ⚙ {block.tool_name} +
+ ) + } + if (block.type === "tool_result") { + return ( +
+ {block.is_error ? "✕" : "✓"} result +
+ ) + } + // image / image_generation — silently omitted in preview + return null +} diff --git a/src/contexts/delegation-context.tsx b/src/contexts/delegation-context.tsx new file mode 100644 index 000000000..4fb2fdf8d --- /dev/null +++ b/src/contexts/delegation-context.tsx @@ -0,0 +1,162 @@ +"use client" + +/** + * DelegationContext — tracks live parent ↔ child delegation bindings + * indexed by `parent_tool_use_id`. + * + * The parent's `delegate_to_agent` ToolCallBlock needs to render the child + * sub-session inline, but the wire events (`delegation_started` / + * `delegation_completed`) arrive on the *child*'s connection stream — there + * is no per-connection-keyed subscription that gives the parent UI access to + * them. This context owns a single global subscription to `acp://event`, + * filters the two delegation variants, and exposes a tool-use-id-keyed + * lookup so ToolCallBlock can resolve the binding by the field it already + * has in hand. + * + * Scope intentionally minimal for Phase 8: + * * State stays in-memory; persistence across reloads relies on the + * parent_tool_use_id stored on the child's DB row (Phase 7). + * * Inline permission routing (child's `permission_request` surfaced on + * parent's ToolCallBlock) is deferred — the existing permission store + * is per-connection and would require a broader reducer change. + */ + +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" + +import type { AgentType, EventEnvelope } from "@/lib/types" +import { subscribe } from "@/lib/platform" + +export type DelegationStatus = "running" | "ok" | "err" + +export interface DelegationBinding { + parentConnectionId: string + parentToolUseId: string + childConnectionId: string + childConversationId: number + agentType: AgentType + status: DelegationStatus + errorCode?: string + durationMs?: number +} + +interface DelegationContextValue { + findByParentToolUseId(id: string): DelegationBinding | undefined + findByChildConversationId(id: number): DelegationBinding | undefined +} + +const DelegationContext = createContext(null) + +export function useDelegation(): DelegationContextValue { + const ctx = useContext(DelegationContext) + if (!ctx) { + throw new Error("useDelegation must be used within DelegationProvider") + } + return ctx +} + +export function DelegationProvider({ children }: { children: ReactNode }) { + const [byToolUseId, setByToolUseId] = useState< + Map + >(() => new Map()) + + useEffect(() => { + let unsubscribed = false + let unsubscribe: (() => void) | null = null + + void (async () => { + const unsub = await subscribe( + "acp://event", + (envelope) => { + if (envelope.type === "delegation_started") { + const next: DelegationBinding = { + parentConnectionId: envelope.parent_connection_id, + parentToolUseId: envelope.parent_tool_use_id, + childConnectionId: envelope.child_connection_id, + childConversationId: envelope.child_conversation_id, + agentType: envelope.agent_type, + status: "running", + } + setByToolUseId((prev) => { + const m = new Map(prev) + m.set(envelope.parent_tool_use_id, next) + return m + }) + return + } + if (envelope.type === "delegation_completed") { + setByToolUseId((prev) => { + const existing = prev.get(envelope.parent_tool_use_id) + // If we missed the start event (e.g. context mounted mid-flight), + // synthesize a minimal binding so the parent UI still shows the + // result. Fields not in the completion payload stay defaulted. + const base: DelegationBinding = existing ?? { + parentConnectionId: envelope.parent_connection_id, + parentToolUseId: envelope.parent_tool_use_id, + childConnectionId: envelope.child_connection_id, + childConversationId: envelope.child_conversation_id, + agentType: "claude_code", + status: "running", + } + const updated: DelegationBinding = + envelope.result.kind === "ok" + ? { + ...base, + status: "ok", + durationMs: envelope.result.duration_ms, + } + : { + ...base, + status: "err", + errorCode: envelope.result.error_code, + } + const m = new Map(prev) + m.set(envelope.parent_tool_use_id, updated) + return m + }) + } + } + ) + + if (unsubscribed) { + unsub() + } else { + unsubscribe = unsub + } + })() + + return () => { + unsubscribed = true + unsubscribe?.() + } + }, []) + + const findByParentToolUseId = useCallback( + (id: string): DelegationBinding | undefined => byToolUseId.get(id), + [byToolUseId] + ) + + const findByChildConversationId = useCallback( + (id: number): DelegationBinding | undefined => { + for (const b of byToolUseId.values()) { + if (b.childConversationId === id) return b + } + return undefined + }, + [byToolUseId] + ) + + return ( + + {children} + + ) +} diff --git a/src/hooks/use-delegated-sub-session.ts b/src/hooks/use-delegated-sub-session.ts new file mode 100644 index 000000000..f08d59afe --- /dev/null +++ b/src/hooks/use-delegated-sub-session.ts @@ -0,0 +1,98 @@ +/** + * Resolves a delegation binding by `parent_tool_use_id` and fetches the + * child conversation's persisted detail (turns + session stats) so the + * parent's ToolCallBlock can render a preview inline. + * + * Returns `{ binding, detail, loading, error }`. `binding` may be undefined + * if the parent UI mounted before the `delegation_started` event was + * delivered (e.g. resuming an old conversation from disk where the live + * broker has long since cleared the binding). In that case `detail` stays + * null; callers may fall back to a placeholder. + * + * The child detail is fetched once and cached for the lifetime of the + * binding — completion of the child causes the binding's status to flip + * but does not invalidate the cached detail; callers re-fetch by remounting + * the hook (e.g. user expands the sub-thread again). + */ + +import { useEffect, useReducer } from "react" +import type { DbConversationDetail } from "@/lib/types" +import { getFolderConversation } from "@/lib/api" +import { + useDelegation, + type DelegationBinding, +} from "@/contexts/delegation-context" + +export interface UseDelegatedSubSessionResult { + binding: DelegationBinding | undefined + detail: DbConversationDetail | null + loading: boolean + error: string | null +} + +interface FetchState { + detail: DbConversationDetail | null + loading: boolean + error: string | null +} + +const INITIAL_STATE: FetchState = { detail: null, loading: false, error: null } + +type FetchAction = + | { kind: "start" } + | { kind: "ok"; detail: DbConversationDetail } + | { kind: "err"; message: string } + +// `useReducer` instead of three `useState` slots so the in-effect transition +// to "fetching" / "ok" / "err" is a single dispatch — `react-hooks/set-state- +// in-effect` only flags raw setState calls, not dispatch. +function fetchReducer(_state: FetchState, action: FetchAction): FetchState { + switch (action.kind) { + case "start": + return { detail: null, loading: true, error: null } + case "ok": + return { detail: action.detail, loading: false, error: null } + case "err": + return { detail: null, loading: false, error: action.message } + } +} + +export function useDelegatedSubSession( + parentToolUseId: string, + options?: { enabled?: boolean } +): UseDelegatedSubSessionResult { + const enabled = options?.enabled ?? true + const { findByParentToolUseId } = useDelegation() + const binding = findByParentToolUseId(parentToolUseId) + const childId = binding?.childConversationId ?? null + const shouldFetch = enabled && childId != null + + const [state, dispatch] = useReducer(fetchReducer, INITIAL_STATE) + + useEffect(() => { + if (!shouldFetch) return + let cancelled = false + dispatch({ kind: "start" }) + void getFolderConversation(childId) + .then((d) => { + if (cancelled) return + dispatch({ kind: "ok", detail: d }) + }) + .catch((err) => { + if (cancelled) return + dispatch({ + kind: "err", + message: err instanceof Error ? err.message : String(err), + }) + }) + + return () => { + cancelled = true + } + }, [shouldFetch, childId]) + + if (!shouldFetch) { + return { binding, detail: null, loading: false, error: null } + } + return { binding, ...state } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 19b4757ed..fb3034ad3 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1837,6 +1837,26 @@ "error": "خطأ", "result": "النتيجة" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.", "result": "النتيجة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index baa62c9fe..c1c0ca877 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1837,6 +1837,26 @@ "error": "Fehler", "result": "Ergebnis" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.", "result": "Ergebnis", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 80320f112..59fcb7ca5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1837,6 +1837,26 @@ "error": "Error", "result": "Result" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "Showing tail output while streaming for performance.", "result": "Result", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 32d96d0e9..4004a4672 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1837,6 +1837,26 @@ "error": "Error de ejecución", "result": "Resultado" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.", "result": "Resultado", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c039b3353..f8f91d2a4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1837,6 +1837,26 @@ "error": "Erreur", "result": "Résultat" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.", "result": "Résultat", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 37ccb57ef..f38a1af14 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1837,6 +1837,26 @@ "error": "エラー", "result": "結果" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。", "result": "結果", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ff1c0d887..16798df41 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1837,6 +1837,26 @@ "error": "오류", "result": "결과" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.", "result": "결과", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4b6ec8d14..29ba1d48f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1837,6 +1837,26 @@ "error": "Erro", "result": "Resultado" }, + "delegation": { + "inFlight": "delegating…", + "loading": "Loading sub-session…", + "loadFailed": "Failed to load sub-session: {detail}", + "noDetail": "No detail available yet.", + "status": { + "running": "running", + "ok": "done", + "err": { + "default": "failed", + "delegation_disabled": "disabled", + "depth_limit": "depth limit", + "invalid_agent_type": "bad agent", + "spawn_failed": "spawn failed", + "send_failed": "send failed", + "timeout": "timeout", + "canceled": "canceled" + } + } + }, "contentParts": { "showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.", "result": "Resultado", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 91ed725f6..5637a14a3 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1837,6 +1837,26 @@ "error": "错误", "result": "结果" }, + "delegation": { + "inFlight": "委托中…", + "loading": "正在加载子会话…", + "loadFailed": "加载子会话失败:{detail}", + "noDetail": "暂无详情。", + "status": { + "running": "运行中", + "ok": "完成", + "err": { + "default": "失败", + "delegation_disabled": "已禁用", + "depth_limit": "深度超限", + "invalid_agent_type": "代理类型无效", + "spawn_failed": "启动失败", + "send_failed": "发送失败", + "timeout": "超时", + "canceled": "已取消" + } + } + }, "contentParts": { "showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。", "result": "结果", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c73e448ca..dad16ea48 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1837,6 +1837,26 @@ "error": "錯誤", "result": "結果" }, + "delegation": { + "inFlight": "委派中…", + "loading": "正在載入子會話…", + "loadFailed": "載入子會話失敗:{detail}", + "noDetail": "暫無詳情。", + "status": { + "running": "執行中", + "ok": "完成", + "err": { + "default": "失敗", + "delegation_disabled": "已停用", + "depth_limit": "深度超限", + "invalid_agent_type": "代理類型無效", + "spawn_failed": "啟動失敗", + "send_failed": "傳送失敗", + "timeout": "逾時", + "canceled": "已取消" + } + } + }, "contentParts": { "showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。", "result": "結果", diff --git a/src/lib/types.ts b/src/lib/types.ts index bd42c19cb..95feeeea7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -583,6 +583,42 @@ export type AcpEvent = used: number size: number } + /** + * A `delegate_to_agent` MCP tool call from the parent agent has spawned a + * child sub-session and the child's prompt is in flight. Emitted as soon as + * the broker registers the pending call. Frontend uses this to build the + * parent ↔ child mapping for inline ToolCallBlock rendering. + */ + | { + type: "delegation_started" + parent_connection_id: string + parent_tool_use_id: string + child_connection_id: string + child_conversation_id: number + agent_type: AgentType + } + /** + * The child sub-session has finished (or errored / timed out / been + * canceled). The MCP tool_result has been delivered to the parent agent; + * frontend updates the ToolCallBlock badge from "running" to ok/err. + */ + | { + type: "delegation_completed" + parent_connection_id: string + parent_tool_use_id: string + child_connection_id: string + child_conversation_id: number + result: DelegationResultSummary + } + +/** + * Mirror of Rust `DelegationResultSummary`. `kind` discriminates Ok vs Err; + * Ok carries `duration_ms` (broker-measured), Err carries a stable code from + * the `DelegationError` taxonomy (e.g. `"timeout"`, `"canceled"`). + */ +export type DelegationResultSummary = + | { kind: "ok"; duration_ms: number } + | { kind: "err"; error_code: string } /** * Wire envelope for all ACP events. JSON shape is flat via Rust's serde From b34113698511fd52c3c6aa7f55f0c2d413c507af Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 06:12:14 +0800 Subject: [PATCH 09/29] feat(delegation): settings UI, chat-channel relay, full i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the multi-agent delegation feature: a settings panel that lets users toggle the kill switch / depth / timeout, a chat-channel summary so Slack/Telegram/etc. relays render delegation calls as one line each instead of dumping the MCP tool I/O, and the i18n coverage that was missing for the AcpAgentSettings.multiAgent block in all 10 locales. * `web/handlers/delegation.rs` (new) + router routes — the HTTP mirror of `get_delegation_settings` / `set_delegation_settings` Tauri commands. Server mode needs them too, otherwise CodeG-server users have no way to tune the broker. * `lib/api.ts` — `getDelegationSettings` / `setDelegationSettings` wrappers + `DelegationSettings` type mirror. No `tauri.ts` entry needed; `getTransport().call()` already routes both modes. * `components/settings/delegation-settings.tsx` (new) — a self-contained section mounted under `/settings/agents` as a sibling of the per-agent settings. Lives in its own file because delegation is a global feature and the existing acp-agent-settings is 7.8k lines. Server-side clamp values are mirrored back into the inputs after save so the UI always reflects what was actually persisted. * `chat_channel/session_event_subscriber.rs` — delegation-aware relay: fires "🤖 Delegating to {agent}…" on ToolCall and "✅/❌ {agent}: {preview}" on ToolCallUpdate completed. Skips the generic `>> {detail}` line for these calls so users see one delegation status, not three. +7 unit tests cover the matchers and outcome formatters (ok / err code / empty / long-text truncation). * i18n — en/zh-CN/zh-TW carry the full localized copy for the settings panel; the other 7 locales get the English fallback to keep parity tests green (next translator pass can fill them in). Verification: cargo test --features test-utils --lib → 443 passed cargo clippy --all-targets --features test-utils → clean cargo check --no-default-features --bin codeg-server --bin codeg-mcp pnpm test → 177 passed pnpm eslint . → clean pnpm build → static export ok Manual acceptance (per spec §15.3) — pending user-driven runs: * Claude Code → Codex flow * Codex → Claude Code reverse flow * Cancel cascade (parent cancel → child stops < 1s) * Timeout (30s setting + slow task → error_code timeout) * Depth limit (3-deep chain at depth=2 → error_code depth_limit; bump depth to 3 → succeeds) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat_channel/session_event_subscriber.rs | 169 +++++++++++++++- src-tauri/src/web/handlers/delegation.rs | 38 ++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/router.rs | 8 + src/app/settings/agents/page.tsx | 6 +- .../message/delegated-sub-thread.test.tsx | 5 +- .../settings/delegation-settings.tsx | 187 ++++++++++++++++++ src/i18n/messages/ar.json | 15 ++ src/i18n/messages/de.json | 15 ++ src/i18n/messages/en.json | 15 ++ src/i18n/messages/es.json | 15 ++ src/i18n/messages/fr.json | 15 ++ src/i18n/messages/ja.json | 15 ++ src/i18n/messages/ko.json | 15 ++ src/i18n/messages/pt.json | 15 ++ src/i18n/messages/zh-CN.json | 15 ++ src/i18n/messages/zh-TW.json | 15 ++ src/lib/api.ts | 18 ++ 18 files changed, 576 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/web/handlers/delegation.rs create mode 100644 src/components/settings/delegation-settings.tsx diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index a5eba2c93..a1600b3b3 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -164,6 +164,18 @@ async fn handle_acp_envelope( raw_input, .. } => { + // Emit a "delegation started" placeholder to the channel so + // remote users see something happen as soon as the parent agent + // fires `delegate_to_agent`, not only when the child wraps up. + let delegation_announce = if is_delegation_title(title) { + raw_input + .as_deref() + .and_then(extract_agent_type) + .map(|agent| format!("🤖 Delegating to {agent}…")) + } else { + None + }; + let mut guard = bridge.lock().await; if let Some(session) = guard.get_mut(connection_id) { // Store title for progress indicator; store raw_input for later @@ -173,6 +185,12 @@ async fn handle_acp_envelope( .tool_call_inputs .insert(tool_call_id.clone(), input.to_string()); } + if let Some(text) = delegation_announce { + let channel_id = session.channel_id; + drop(guard); + let msg = RichMessage::info(text); + let _ = manager.send_to_channel(channel_id, &msg).await; + } } } @@ -181,6 +199,7 @@ async fn handle_acp_envelope( title, status, raw_input, + raw_output, .. } => { let mut guard = bridge.lock().await; @@ -196,11 +215,20 @@ async fn handle_acp_envelope( let stored_input = session.tool_call_inputs.remove(tool_call_id); let effective_title = title.as_deref().unwrap_or("tool"); let input_ref = stored_input.as_deref().or(raw_input.as_deref()); - let detail = format_tool_call_detail(effective_title, input_ref); let channel_id = session.channel_id; + + let body = if is_delegation_title(effective_title) + || input_ref + .map(|s| extract_agent_type(s).is_some()) + .unwrap_or(false) + { + format_delegation_outcome(input_ref, raw_output.as_deref()) + } else { + format!(">> {}", format_tool_call_detail(effective_title, input_ref)) + }; drop(guard); - let msg = RichMessage::info(format!(">> {detail}")); + let msg = RichMessage::info(body); let _ = manager.send_to_channel(channel_id, &msg).await; } } @@ -692,3 +720,140 @@ fn truncate_str(s: &str, max: usize) -> String { format!("{truncated}...") } } + +/// Title-side match for `delegate_to_agent`. Matches both the literal MCP +/// tool name and the human-readable title agents sometimes substitute. +fn is_delegation_title(title: &str) -> bool { + let normalized = title.to_lowercase().replace([' ', '-'], "_"); + normalized == "delegate_to_agent" +} + +/// Pull `agent_type` out of the raw_input JSON (e.g. `{"agent_type":"codex", +/// "task":"..."}`). Returns the canonical string the agent supplied so the +/// announce message matches what the user wrote, not a re-mapped label. +fn extract_agent_type(raw_input: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(raw_input).ok()?; + parsed + .get("agent_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Build the chat-channel summary for a finished `delegate_to_agent` call. +/// Receives the broker's wire payload (already a JSON-serialized +/// `DelegationOutcome`) and renders a compact ✅/❌ line plus the short +/// preview text the user can act on. +fn format_delegation_outcome(raw_input: Option<&str>, raw_output: Option<&str>) -> String { + let agent = raw_input + .and_then(extract_agent_type) + .unwrap_or_else(|| "agent".to_string()); + + // Try to parse the MCP-style structured output Phase 5 emits: + // `{ "kind": "ok", "text": "…", … }` or `{ "kind": "err", "code": "…" }`. + // Fall back to the plain text body if the agent already collapsed it. + if let Some(out) = raw_output { + if let Ok(value) = serde_json::from_str::(out) { + let kind = value.get("kind").and_then(|v| v.as_str()); + match kind { + Some("ok") => { + let text = value + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if text.is_empty() { + return format!("✅ {agent} done"); + } + let preview = truncate_str(text, 200); + return format!("✅ {agent}: {preview}"); + } + Some("err") => { + let code = value.get("code").and_then(|v| v.as_str()).unwrap_or("err"); + return format!("❌ {agent} failed ({code})"); + } + _ => {} + } + } + let preview = truncate_str(out.trim(), 200); + if !preview.is_empty() { + return format!("✅ {agent}: {preview}"); + } + } + format!("✅ {agent} done") +} + +#[cfg(test)] +mod delegation_relay_tests { + use super::*; + + #[test] + fn is_delegation_title_matches_variants() { + assert!(is_delegation_title("delegate_to_agent")); + assert!(is_delegation_title("Delegate To Agent")); + assert!(is_delegation_title("delegate-to-agent")); + assert!(!is_delegation_title("agent")); + assert!(!is_delegation_title("write")); + } + + #[test] + fn extract_agent_type_pulls_canonical_string() { + assert_eq!( + extract_agent_type(r#"{"agent_type":"codex","task":"x"}"#), + Some("codex".into()) + ); + assert_eq!(extract_agent_type(r#"{"task":"x"}"#), None); + assert_eq!(extract_agent_type("not json"), None); + } + + #[test] + fn format_delegation_outcome_renders_ok_with_preview() { + let out = r#"{"kind":"ok","text":" hello world "}"#; + let body = format_delegation_outcome( + Some(r#"{"agent_type":"codex"}"#), + Some(out), + ); + assert_eq!(body, "✅ codex: hello world"); + } + + #[test] + fn format_delegation_outcome_renders_err_with_code() { + let out = r#"{"kind":"err","code":"timeout"}"#; + let body = format_delegation_outcome( + Some(r#"{"agent_type":"gemini"}"#), + Some(out), + ); + assert_eq!(body, "❌ gemini failed (timeout)"); + } + + #[test] + fn format_delegation_outcome_falls_back_to_plain_text() { + let body = format_delegation_outcome( + Some(r#"{"agent_type":"cline"}"#), + Some("plain reply body"), + ); + assert_eq!(body, "✅ cline: plain reply body"); + } + + #[test] + fn format_delegation_outcome_empty_output_marks_done() { + let body = format_delegation_outcome( + Some(r#"{"agent_type":"open_code"}"#), + None, + ); + assert_eq!(body, "✅ open_code done"); + } + + #[test] + fn format_delegation_outcome_truncates_long_ok_text() { + let long_text = "x".repeat(400); + let out = format!(r#"{{"kind":"ok","text":"{long_text}"}}"#); + let body = format_delegation_outcome( + Some(r#"{"agent_type":"codex"}"#), + Some(&out), + ); + // 200-char cap + "..." + assert!(body.len() < 300); + assert!(body.starts_with("✅ codex: ")); + assert!(body.ends_with("...")); + } +} diff --git a/src-tauri/src/web/handlers/delegation.rs b/src-tauri/src/web/handlers/delegation.rs new file mode 100644 index 000000000..f88945a30 --- /dev/null +++ b/src-tauri/src/web/handlers/delegation.rs @@ -0,0 +1,38 @@ +//! HTTP handlers for delegation settings — the web-mode mirror of the +//! Tauri commands in `commands::delegation`. +//! +//! Both endpoints share the same core helpers (`load_delegation_settings`, +//! `set_delegation_settings_core`) so the clamp + persist + broker +//! re-apply behavior stays identical across transports. + +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::delegation::{ + load_delegation_settings, set_delegation_settings_core, DelegationSettings, +}; + +pub async fn get_delegation_settings( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(load_delegation_settings(&state.db.conn).await)) +} + +#[derive(Deserialize)] +pub struct SetDelegationSettingsParams { + pub settings: DelegationSettings, +} + +pub async fn set_delegation_settings( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let saved = + set_delegation_settings_core(&state.db.conn, &state.delegation_broker, params.settings) + .await?; + Ok(Json(saved)) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 04ec481ff..59bf297f6 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod acp; pub mod chat_channel; pub mod conversations; +pub mod delegation; mod error; pub mod event_metrics; pub mod experts; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index af280a951..23c5849be 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -56,6 +56,14 @@ pub fn build_router( "/list_child_conversations", post(handlers::conversations::list_child_conversations), ) + .route( + "/get_delegation_settings", + post(handlers::delegation::get_delegation_settings), + ) + .route( + "/set_delegation_settings", + post(handlers::delegation::set_delegation_settings), + ) .route( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), diff --git a/src/app/settings/agents/page.tsx b/src/app/settings/agents/page.tsx index eed094a29..1ce4e1b97 100644 --- a/src/app/settings/agents/page.tsx +++ b/src/app/settings/agents/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react" import { useTranslations } from "next-intl" import { AcpAgentSettings } from "@/components/settings/acp-agent-settings" +import { DelegationSettingsSection } from "@/components/settings/delegation-settings" export default function SettingsAgentsPage() { const t = useTranslations("SettingsPages") @@ -15,7 +16,10 @@ export default function SettingsAgentsPage() { } > - +
+ + +
) } diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index fa867fedb..62f10376f 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -10,9 +10,8 @@ vi.mock("@/hooks/use-delegated-sub-session", () => ({ useDelegatedSubSession: vi.fn(), })) -const { useDelegatedSubSession } = await import( - "@/hooks/use-delegated-sub-session" -) +const { useDelegatedSubSession } = + await import("@/hooks/use-delegated-sub-session") const mockedHook = vi.mocked(useDelegatedSubSession) function renderWithIntl(ui: React.ReactElement) { diff --git a/src/components/settings/delegation-settings.tsx b/src/components/settings/delegation-settings.tsx new file mode 100644 index 000000000..e60a539c1 --- /dev/null +++ b/src/components/settings/delegation-settings.tsx @@ -0,0 +1,187 @@ +"use client" + +/** + * Multi-agent delegation settings panel. Owns the three knobs persisted by + * `set_delegation_settings_core` on the Rust side: + * + * * `enabled` — feature kill switch + * * `depth_limit` — bounds chain depth (1..=8) + * * `default_timeout_seconds` — broker fallback when LLM omits it (30..=3600) + * + * Lives on its own under `/settings/agents` rather than wedged into the + * 7,800-line `acp-agent-settings.tsx` because delegation is a global feature + * — not per-agent — and the existing file has no clean tail anchor that + * isn't already inside a per-agent collapse. Mounted as a sibling of + * `` so users land on it from the same nav entry. + */ + +import { useCallback, useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2, Workflow } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { + type DelegationSettings, + getDelegationSettings, + setDelegationSettings, +} from "@/lib/api" + +const DEPTH_MIN = 1 +const DEPTH_MAX = 8 +const TIMEOUT_MIN = 30 +const TIMEOUT_MAX = 3600 + +function clamp(n: number, lo: number, hi: number): number { + if (!Number.isFinite(n)) return lo + return Math.min(hi, Math.max(lo, Math.trunc(n))) +} + +export function DelegationSettingsSection() { + const t = useTranslations("AcpAgentSettings.multiAgent") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [enabled, setEnabled] = useState(true) + const [depth, setDepth] = useState(2) + const [timeout, setTimeoutValue] = useState(600) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + let cancelled = false + void getDelegationSettings() + .then((s) => { + if (cancelled) return + setEnabled(s.enabled) + setDepth(s.depth_limit) + setTimeoutValue(s.default_timeout_seconds) + setLoadError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + setLoadError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (cancelled) return + setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const save = useCallback(async () => { + const payload: DelegationSettings = { + enabled, + depth_limit: clamp(depth, DEPTH_MIN, DEPTH_MAX), + default_timeout_seconds: clamp(timeout, TIMEOUT_MIN, TIMEOUT_MAX), + } + setSaving(true) + try { + const applied = await setDelegationSettings(payload) + // Mirror any server-side clamps back into the UI so the inputs reflect + // what was actually persisted. + setEnabled(applied.enabled) + setDepth(applied.depth_limit) + setTimeoutValue(applied.default_timeout_seconds) + toast.success(t("saved")) + } catch (err: unknown) { + toast.error(t("saveFailed"), { + description: err instanceof Error ? err.message : String(err), + }) + } finally { + setSaving(false) + } + }, [enabled, depth, timeout, t]) + + return ( +
+
+ +

{t("title")}

+
+

+ {t("description")} +

+ + {loadError && ( +

+ {t("loadFailed", { detail: loadError })} +

+ )} + +
+
+
+ +

{t("enableHint")}

+
+ +
+ +
+
+ +

+ {t("depthHint", { min: DEPTH_MIN, max: DEPTH_MAX })} +

+
+ setDepth(Number(e.target.value))} + disabled={loading || !enabled} + className="w-24" + /> +
+ +
+
+ +

+ {t("timeoutHint", { min: TIMEOUT_MIN, max: TIMEOUT_MAX })} +

+
+ setTimeoutValue(Number(e.target.value))} + disabled={loading || !enabled} + className="w-32" + /> +
+
+ +
+ +
+
+ ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index fb3034ad3..35b91abdf 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -496,6 +496,21 @@ "modelHintDefault": "اتركه فارغًا لاستخدام النموذج الافتراضي للنظام.", "generalConfigDescriptionClaude": "يدعم الإعداد السريع لـ API URL وAPI Key ونماذج Claude، ويتزامن مع إعداد JSON الأصلي.", "generalConfigDescriptionDefault": "يدعم إدخال الإعدادات المهمة (API URL وAPI Key وModel) وإدارة إعداد JSON الأصلي.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "اسحب لإعادة الترتيب", "dragSortAgent": "اسحب لإعادة ترتيب {name}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c1c0ca877..9b4fce3aa 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -496,6 +496,21 @@ "modelHintDefault": "Leer lassen, um das System-Standardmodell zu verwenden.", "generalConfigDescriptionClaude": "Unterstützt schnelle Konfiguration von API-URL, API-Key und Claude-Modellen und synchronisiert mit der nativen JSON-Konfiguration.", "generalConfigDescriptionDefault": "Unterstützt wichtige Konfigurationseingaben (API URL, API Key, Model) und native JSON-Konfigurationsverwaltung.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "Zum Neuordnen ziehen", "dragSortAgent": "{name} zum Neuordnen ziehen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 59fcb7ca5..8b82063f2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -496,6 +496,21 @@ "modelHintDefault": "Leave empty to use system default model.", "generalConfigDescriptionClaude": "Supports quick configuration for API URL, API Key and Claude models, and syncs with native JSON config.", "generalConfigDescriptionDefault": "Supports important config input (API URL, API Key, Model) and native JSON config management.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "Drag to reorder", "dragSortAgent": "Drag to reorder {name}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 4004a4672..5a9c3c3fd 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -496,6 +496,21 @@ "modelHintDefault": "Déjalo vacío para usar el modelo predeterminado del sistema.", "generalConfigDescriptionClaude": "Admite configuración rápida de API URL, API Key y modelos de Claude, y sincroniza con la configuración JSON nativa.", "generalConfigDescriptionDefault": "Admite entrada de configuración importante (API URL, API Key, Model) y gestión de configuración JSON nativa.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "Arrastrar para reordenar", "dragSortAgent": "Arrastrar para reordenar {name}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index f8f91d2a4..34f642d24 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -496,6 +496,21 @@ "modelHintDefault": "Laissez vide pour utiliser le modèle système par défaut.", "generalConfigDescriptionClaude": "Prend en charge la configuration rapide de l'API URL, API Key et des modèles Claude, et synchronise avec la configuration JSON native.", "generalConfigDescriptionDefault": "Prend en charge les entrées de configuration importantes (API URL, API Key, Model) et la gestion de la configuration JSON native.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "Glisser pour réordonner", "dragSortAgent": "Glisser pour réordonner {name}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f38a1af14..23650b8ef 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -496,6 +496,21 @@ "modelHintDefault": "空欄の場合はシステム既定モデルを使用します。", "generalConfigDescriptionClaude": "API URL、API Key、Claude モデルをすばやく設定でき、ネイティブ JSON 設定と同期します。", "generalConfigDescriptionDefault": "重要な設定入力(API URL、API Key、Model)とネイティブ JSON 設定管理をサポートします。", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "ドラッグして並べ替え", "dragSortAgent": "{name} をドラッグして並べ替え", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 16798df41..31b61bbb8 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -496,6 +496,21 @@ "modelHintDefault": "비워두면 시스템 기본 모델을 사용합니다.", "generalConfigDescriptionClaude": "API URL, API Key, Claude 모델을 빠르게 설정하고 네이티브 JSON 구성과 동기화합니다.", "generalConfigDescriptionDefault": "주요 구성 입력(API URL, API Key, Model)과 네이티브 JSON 구성 관리를 지원합니다.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "드래그하여 순서 변경", "dragSortAgent": "{name} 순서 변경", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 29ba1d48f..377050577 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -496,6 +496,21 @@ "modelHintDefault": "Deixe em branco para usar o modelo padrão do sistema.", "generalConfigDescriptionClaude": "Suporta configuração rápida de API URL, API Key e modelos Claude, e sincroniza com a configuração JSON nativa.", "generalConfigDescriptionDefault": "Suporta entrada de configuração importante (API URL, API Key, Model) e gerenciamento de configuração JSON nativa.", + "multiAgent": { + "title": "Multi-Agent Collaboration", + "description": "Allow active agents to delegate sub-tasks to other agent types via the codeg delegation MCP. Sub-sessions run in their own ACP connections and stream back inline.", + "enable": "Enable delegation", + "enableHint": "When off, the delegate_to_agent tool is hidden from the agent's MCP catalog.", + "depthLimit": "Maximum delegation depth", + "depthHint": "Allowed range: {min}–{max}. Caps how deep a chain (root → child → grandchild …) can recurse.", + "timeoutSeconds": "Default timeout (seconds)", + "timeoutHint": "Allowed range: {min}–{max}. Applied when the calling agent does not specify timeout_seconds.", + "save": "Save", + "saving": "Saving…", + "saved": "Delegation settings saved", + "saveFailed": "Failed to save delegation settings", + "loadFailed": "Failed to load delegation settings: {detail}" + }, "actions": { "dragSort": "Arraste para reordenar", "dragSortAgent": "Arraste para reordenar {name}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 5637a14a3..a7cb0dd8d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -496,6 +496,21 @@ "modelHintDefault": "留空则使用系统默认模型。", "generalConfigDescriptionClaude": "支持 API URL、API Key 与 Claude 模型快捷配置,并与原生 JSON 配置联动。", "generalConfigDescriptionDefault": "支持重要配置输入(API URL、API Key、Model)和原生 JSON 配置管理。", + "multiAgent": { + "title": "多智能体协同", + "description": "允许活跃的智能体通过 codeg 委托 MCP 把子任务交给其他智能体。子会话在独立的 ACP 连接里运行,结果会内联回流到父对话。", + "enable": "启用委托", + "enableHint": "关闭后,delegate_to_agent 工具会从智能体的 MCP 工具清单中隐藏。", + "depthLimit": "最大委托深度", + "depthHint": "允许范围:{min}–{max}。限制委托链(根 → 子 → 孙 …)的最大递归深度。", + "timeoutSeconds": "默认超时(秒)", + "timeoutHint": "允许范围:{min}–{max}。当调用方未指定 timeout_seconds 时使用。", + "save": "保存", + "saving": "保存中…", + "saved": "委托设置已保存", + "saveFailed": "委托设置保存失败", + "loadFailed": "加载委托设置失败:{detail}" + }, "actions": { "dragSort": "拖拽排序", "dragSortAgent": "拖拽排序 {name}", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index dad16ea48..2ff3a2dcf 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -496,6 +496,21 @@ "modelHintDefault": "留空則使用系統預設模型。", "generalConfigDescriptionClaude": "支援 API URL、API Key 與 Claude 模型快捷配置,並與原生 JSON 配置聯動。", "generalConfigDescriptionDefault": "支援重要配置輸入(API URL、API Key、Model)和原生 JSON 配置管理。", + "multiAgent": { + "title": "多智慧體協同", + "description": "允許活躍的智慧體透過 codeg 委派 MCP 將子任務交給其他智慧體。子會話會在獨立的 ACP 連線中執行,結果以內嵌方式回流到父對話。", + "enable": "啟用委派", + "enableHint": "關閉後,delegate_to_agent 工具會從智慧體的 MCP 工具清單中隱藏。", + "depthLimit": "最大委派深度", + "depthHint": "允許範圍:{min}–{max}。限制委派鏈(根 → 子 → 孫 …)的最大遞迴深度。", + "timeoutSeconds": "預設逾時(秒)", + "timeoutHint": "允許範圍:{min}–{max}。當呼叫方未指定 timeout_seconds 時使用。", + "save": "儲存", + "saving": "儲存中…", + "saved": "委派設定已儲存", + "saveFailed": "委派設定儲存失敗", + "loadFailed": "載入委派設定失敗:{detail}" + }, "actions": { "dragSort": "拖拽排序", "dragSortAgent": "拖拽排序 {name}", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9ba9b27b6..aa41322cc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2419,3 +2419,21 @@ export async function updateModelProvider(params: { export async function deleteModelProvider(id: number): Promise { return getTransport().call("delete_model_provider", { id }) } + +// ─── Delegation settings ─────────────────────────────────────────────── + +export interface DelegationSettings { + enabled: boolean + depth_limit: number + default_timeout_seconds: number +} + +export async function getDelegationSettings(): Promise { + return getTransport().call("get_delegation_settings") +} + +export async function setDelegationSettings( + settings: DelegationSettings +): Promise { + return getTransport().call("set_delegation_settings", { settings }) +} From f5b16142539e38166fcbf1481511d4a36f6357d0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 07:21:07 +0800 Subject: [PATCH 10/29] fix(delegation): visible settings, MCP _meta fallback, agent card UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * settings/agents page: wrap in a scrolling container so the global delegation section is reachable below AcpAgentSettings (which still claims h-full for its grid layout). * broker: ACP clients (Codex/Claude Code) don't populate `_meta.tool_use_id`, so the companion no longer rejects calls without it; instead, the lifecycle subscribes to parent ToolCall events and pushes their tool_call_ids into a per-connection FIFO that the broker claims when an MCP round-trip arrives (UUID fallback when empty). * tool-call renderer: render `delegate_to_agent` invocations as a dedicated AgentIcon-driven card with the task text pulled directly from the LLM input — even before the DelegationStarted event lands. Tool-name normalization picks up the `mcp____delegate_to_agent` prefix. * i18n: three new strings (`delegatedLabel`, `unknownAgent`, `waitingForChild`) localized for zh-CN / zh-TW / ja / ko / es / de / fr / pt / ar; English-only fallback dropped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/delegation/broker.rs | 200 ++++++++++++++++- src-tauri/src/acp/delegation/companion.rs | 23 +- src-tauri/src/acp/lifecycle.rs | 54 +++++ src/app/settings/agents/page.tsx | 12 +- .../message/content-parts-renderer.tsx | 25 ++- .../message/delegated-sub-thread.test.tsx | 24 ++- .../message/delegated-sub-thread.tsx | 202 +++++++++++++----- src/i18n/messages/ar.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/lib/tool-call-normalization.ts | 10 + 18 files changed, 502 insertions(+), 78 deletions(-) diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 407647059..a92421f48 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -24,7 +24,7 @@ //! [`DelegationBroker::cancel_by_parent`] which fans out cancel + disconnect //! to every pending child of that parent. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -75,6 +75,22 @@ struct PendingCalls { inner: Mutex>, } +/// FIFO of `tool_call_id`s that the ACP lifecycle observed firing +/// `delegate_to_agent` on a given parent connection but for which the +/// matching broker round-trip has not yet arrived. MCP clients (Codex, +/// Claude Code) generally do NOT populate `_meta.tool_use_id` when +/// invoking an MCP tool, so the broker can't read the LLM-issued +/// `tool_use_id` from the wire — we capture it from the parallel ACP +/// `tool_call` event stream instead. +/// +/// ACP `sessionUpdate(tool_call)` almost always lands ahead of the +/// agent's own MCP `tools/call`, so this LRU resolves the race in +/// practice. Stale entries (if any) get evicted on parent disconnect. +#[derive(Default)] +struct PendingToolCalls { + inner: Mutex>>, +} + /// The broker is intentionally `Clone` (cheap — only `Arc`s inside) so /// listener/handler code can hand copies to spawned tasks without lifetime /// gymnastics. @@ -83,6 +99,7 @@ pub struct DelegationBroker { spawner: Arc, depth_lookup: Arc, pending: Arc, + pending_tool_calls: Arc, config: Arc>, } @@ -95,10 +112,55 @@ impl DelegationBroker { spawner, depth_lookup, pending: Arc::new(PendingCalls::default()), + pending_tool_calls: Arc::new(PendingToolCalls::default()), config: Arc::new(Mutex::new(DelegationConfig::default())), } } + /// Record a parent ACP `tool_call_id` whose title indicates the LLM is + /// invoking `delegate_to_agent`. The next broker round-trip from the + /// same `parent_connection_id` will claim this id as its + /// `parent_tool_use_id`. Bounded FIFO per connection. + pub async fn register_pending_tool_call( + &self, + parent_connection_id: &str, + tool_call_id: String, + ) { + let mut map = self.pending_tool_calls.inner.lock().await; + let queue = map + .entry(parent_connection_id.to_string()) + .or_insert_with(VecDeque::new); + // Defensive cap so an agent that fires many delegations without ever + // round-tripping can't grow this map without bound. + if queue.len() >= 32 { + queue.pop_front(); + } + queue.push_back(tool_call_id); + } + + /// Pop the oldest pending `tool_call_id` for the given parent, if any. + pub async fn take_pending_tool_call(&self, parent_connection_id: &str) -> Option { + let mut map = self.pending_tool_calls.inner.lock().await; + let queue = map.get_mut(parent_connection_id)?; + let id = queue.pop_front(); + if queue.is_empty() { + map.remove(parent_connection_id); + } + id + } + + /// Forget every pending tool_call id for the given parent. Called when + /// the parent connection tears down so stale ids don't bind to a future + /// reuse of the same connection_id (UUIDs make that unlikely but cheap + /// to defend against). + pub async fn drop_pending_tool_calls_for_parent(&self, parent_connection_id: &str) { + self.pending_tool_calls + .inner + .lock() + .await + .remove(parent_connection_id); + } + pub async fn set_config(&self, cfg: DelegationConfig) { *self.config.lock().await = cfg; } @@ -109,7 +171,19 @@ impl DelegationBroker { /// Entry point. Drives the full lifecycle and returns whatever the parent /// LLM should see as the `delegate_to_agent` tool_result. - pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome { + pub async fn handle_request(&self, mut req: DelegationRequest) -> DelegationOutcome { + // MCP clients usually don't populate `_meta.tool_use_id`, so the + // listener will pass through an empty string. Best-effort claim the + // most recent ACP-side `tool_call_id` for this parent; otherwise + // mint a placeholder UUID so the rest of the lifecycle still works + // (the only downside of the fallback is the UI not being able to + // attach the sub-thread under the parent's ToolCallBlock). + if req.parent_tool_use_id.is_empty() { + req.parent_tool_use_id = self + .take_pending_tool_call(&req.parent_connection_id) + .await + .unwrap_or_else(|| format!("delegation-{}", uuid::Uuid::new_v4())); + } let cfg = self.config_snapshot().await; if !cfg.enabled { return DelegationOutcome::from_err( @@ -295,6 +369,11 @@ impl DelegationBroker { /// Used when a parent session disconnects or the user cancels the parent's /// active prompt. pub async fn cancel_by_parent(&self, parent_connection_id: &str) { + // Also drain any tool_call ids that were captured ahead of an MCP + // round-trip that never arrived — keeps the map bounded across + // parent reconnects. + self.drop_pending_tool_calls_for_parent(parent_connection_id) + .await; let drained: Vec = { let mut map = self.pending.inner.lock().await; let keys: Vec = map @@ -655,6 +734,123 @@ mod tests { } } + // -- Pending tool_call_id queue (MCP `_meta.tool_use_id` fallback) ---- + + #[tokio::test] + async fn pending_tool_call_register_and_take_is_fifo() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker.register_pending_tool_call("p1", "tc-a".into()).await; + broker.register_pending_tool_call("p1", "tc-b".into()).await; + assert_eq!(broker.take_pending_tool_call("p1").await.as_deref(), Some("tc-a")); + assert_eq!(broker.take_pending_tool_call("p1").await.as_deref(), Some("tc-b")); + assert!(broker.take_pending_tool_call("p1").await.is_none()); + } + + #[tokio::test] + async fn pending_tool_call_is_isolated_per_parent() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker.register_pending_tool_call("p1", "p1-a".into()).await; + broker.register_pending_tool_call("p2", "p2-a".into()).await; + assert_eq!(broker.take_pending_tool_call("p1").await.as_deref(), Some("p1-a")); + assert_eq!(broker.take_pending_tool_call("p2").await.as_deref(), Some("p2-a")); + assert!(broker.take_pending_tool_call("p1").await.is_none()); + assert!(broker.take_pending_tool_call("p2").await.is_none()); + } + + #[tokio::test] + async fn empty_parent_tool_use_id_claims_pending_then_completes() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(7)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + broker + .register_pending_tool_call("parent-conn", "tu-from-acp".into()) + .await; + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { + broker.handle_request(request(1, "")).await + }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // The captured ACP id was consumed. + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 7, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + #[tokio::test] + async fn empty_parent_tool_use_id_with_no_pending_falls_back_to_uuid() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(11)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { + broker.handle_request(request(1, "")).await + }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "fallback ok".into(), + child_conversation_id: 11, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + #[tokio::test] + async fn cancel_by_parent_also_drops_pending_tool_calls() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker.register_pending_tool_call("parent-conn", "tu-1".into()).await; + broker.cancel_by_parent("parent-conn").await; + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + } + #[tokio::test] async fn depth_limit_allows_root() { let mock = Arc::new(MockSpawner::new()); diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index e68735df1..b5ca18d60 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -140,18 +140,18 @@ async fn handle_tool_call(ctx: &CompanionContext, id: Value, params: Value) -> J return err(id, -32602, format!("unknown tool: {name}")); } let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); - // MCP passes the LLM-issued tool_use_id under `_meta.tool_use_id`. Without - // it the broker can't bind the eventual child outcome back to the parent's - // ToolUse — reject loudly. + // MCP clients (Codex / Claude Code) generally do NOT populate + // `_meta.tool_use_id` when calling an MCP server. We still surface it + // when present (it's the most precise binding), but a missing one is + // expected — the broker falls back to claiming the most recent + // `delegate_to_agent` tool_call_id observed on the parent's ACP event + // stream. let tool_use_id = params .get("_meta") .and_then(|m| m.get("tool_use_id")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if tool_use_id.is_empty() { - return err(id, -32602, "missing _meta.tool_use_id"); - } let req = BrokerRequest { token: ctx.token.clone(), @@ -271,7 +271,12 @@ mod tests { } #[tokio::test] - async fn tools_call_without_tool_use_id_rejected() { + async fn tools_call_without_tool_use_id_passes_through_to_broker() { + // MCP clients (Codex / Claude Code) generally don't fill + // `_meta.tool_use_id`, so the companion must NOT reject the call — + // it must forward to the broker, which falls back to claiming the + // most recent ACP-side tool_call_id. With a bogus socket path the + // round-trip fails downstream, surfacing as -32603 (NOT -32602). let line = r#"{ "jsonrpc":"2.0", "id":4, @@ -283,8 +288,8 @@ mod tests { }"#; let resp = handle_line(&ctx(), line).await.unwrap(); let e = resp.error.unwrap(); - assert_eq!(e.code, -32602); - assert!(e.message.contains("_meta.tool_use_id")); + assert_eq!(e.code, -32603); + assert!(e.message.contains("broker round-trip")); } #[test] diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index f4f40e93c..700b471bc 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -136,6 +136,24 @@ pub(crate) async fn handle_event( broker: Option<&Arc>, ) -> Result<(), DbError> { match &envelope.payload { + AcpEvent::ToolCall { + tool_call_id, title, .. + } => { + // MCP clients don't reliably populate `_meta.tool_use_id`, so we + // capture every parent-side `delegate_to_agent` tool_call_id + // here. The broker pops the most recent one when the matching + // MCP round-trip arrives. See [`DelegationBroker::register_pending_tool_call`]. + if let Some(b) = broker { + if is_delegation_tool_title(title) { + b.register_pending_tool_call( + &envelope.connection_id, + tool_call_id.clone(), + ) + .await; + } + } + Ok(()) + } AcpEvent::SessionStarted { session_id } => { // Look up conversation_id from the live state. let Some(state_arc) = manager.get_state(&envelope.connection_id).await else { @@ -403,6 +421,42 @@ async fn forward_disconnect_to_broker(broker: &DelegationBroker, connection_id: broker.cancel_by_child_connection(connection_id).await; } +/// True when the ACP `tool_call.title` looks like an invocation of the +/// `delegate_to_agent` MCP tool. Matches both the bare schema name and the +/// `mcp____delegate_to_agent` prefix Codex/Claude Code emit. +fn is_delegation_tool_title(title: &str) -> bool { + let lower = title.to_ascii_lowercase(); + let normalized = lower.replace([' ', '-'], "_"); + normalized == "delegate_to_agent" || normalized.ends_with("__delegate_to_agent") +} + +#[cfg(test)] +mod delegation_title_tests { + use super::is_delegation_tool_title; + + #[test] + fn matches_bare_name() { + assert!(is_delegation_tool_title("delegate_to_agent")); + assert!(is_delegation_tool_title("Delegate To Agent")); + assert!(is_delegation_tool_title("delegate-to-agent")); + } + + #[test] + fn matches_mcp_prefixed_name() { + assert!(is_delegation_tool_title( + "mcp__codeg-delegate__delegate_to_agent" + )); + assert!(is_delegation_tool_title("mcp__codeg__delegate_to_agent")); + } + + #[test] + fn rejects_unrelated_tools() { + assert!(!is_delegation_tool_title("write")); + assert!(!is_delegation_tool_title("agent")); + assert!(!is_delegation_tool_title("delegate_other_thing")); + } +} + /// Per-connection worker that owns the cache for one connection and /// serializes its DB writes. Multiple connections run in parallel; within a /// connection, ordering is preserved by the mpsc FIFO. Decouples the bus diff --git a/src/app/settings/agents/page.tsx b/src/app/settings/agents/page.tsx index 1ce4e1b97..a14a789d7 100644 --- a/src/app/settings/agents/page.tsx +++ b/src/app/settings/agents/page.tsx @@ -16,9 +16,15 @@ export default function SettingsAgentsPage() { } > -
- - +
+
+
+ +
+
+ +
+
) diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 4c50d99d7..391ef9af7 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2294,11 +2294,26 @@ const ToolCallPart = memo(function ToolCallPart({ } // Multi-agent delegation tool: surfaces an inline DelegatedSubThread - // bound to the child sub-session via parent_tool_use_id. Falls through to - // the normal renderer when no toolCallId is available (snapshot replays - // without a live binding) so the user still sees the tool input/output. - if (toolNameLower === "delegate_to_agent" && part.toolCallId) { - return + // bound to the child sub-session via parent_tool_use_id. Matches both + // the bare `delegate_to_agent` (post-normalization) and the MCP-prefixed + // form (`mcp____delegate_to_agent`) as a defensive fallback. + // Falls through to the normal renderer when no toolCallId is available + // (snapshot replays without a live binding) so the user still sees the + // tool input/output. + if ( + (toolNameLower === "delegate_to_agent" || + toolNameLower.endsWith("__delegate_to_agent")) && + part.toolCallId + ) { + return ( + + ) } // Cline: attempt_completion — render as an expanded card with result + progress diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index 62f10376f..16159d00f 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -35,7 +35,7 @@ function bindingOf(overrides: Partial): DelegationBinding { } describe("DelegatedSubThread", () => { - it("renders nothing when no binding exists yet", () => { + it("renders nothing when there's no binding and no parseable input", () => { mockedHook.mockReturnValue({ binding: undefined, detail: null, @@ -56,12 +56,32 @@ describe("DelegatedSubThread", () => { error: null, }) renderWithIntl() - expect(screen.getByText("Codex")).toBeInTheDocument() + // AgentIcon's Codex + the visible label both produce + // "Codex" matches; assert there are *some* matches and the name is + // present in the visible card header. + expect(screen.getAllByText("Codex").length).toBeGreaterThan(0) expect(screen.getByText("running")).toBeInTheDocument() + expect(screen.getByText("· delegated")).toBeInTheDocument() // collapsed by default — sub-thread body not present expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() }) + it("renders the task line directly from input even without a binding", () => { + mockedHook.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + const input = JSON.stringify({ + agent_type: "codex", + task: "summarize the failing tests", + }) + renderWithIntl() + expect(screen.getByText("summarize the failing tests")).toBeInTheDocument() + expect(screen.getAllByText("Codex").length).toBeGreaterThan(0) + }) + it("shows the error badge with the localized code", () => { mockedHook.mockReturnValue({ binding: bindingOf({ status: "err", errorCode: "timeout" }), diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 28d0fc8a1..aab984ef9 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -2,39 +2,87 @@ /** * Inline rendering of a delegated child sub-session under the parent's - * `delegate_to_agent` ToolCallBlock. Default state is a single-row header - * (agent type + status badge + last-message snippet); clicking the chevron - * expands a scrollable preview of the child's turns. + * `delegate_to_agent` ToolCallBlock. Renders as a self-contained card — + * never falls through the generic tool-call shell — so users see "Agent + * delegating: task" instead of "mcp__codeg-delegate__delegate_to_agent: codex". * - * Scope intentionally lean for Phase 8: - * * Only `text` and `thinking` content blocks are rendered in the - * preview body — tool_use / tool_result / image are summarized as a - * compact "tool" line. Phase 9 may upgrade to the full - * content-parts-renderer if the user-facing value justifies the size. - * * No virtualization — typical delegated sessions are small (≤ ~20 - * turns); if a user delegates a long-running task, the parent block - * stays scrollable and the user can navigate to the child conversation - * directly. - * * `loading` is only shown for the first fetch. The binding's status - * transition (running → ok/err) does NOT trigger a re-fetch — callers - * who want the latest turns can collapse + re-expand. + * Layout: + * * Header (always visible): AgentIcon + agent name · "delegated" label + * + status badge + chevron. + * * Task row: the prompt the parent sent to the child. + * * Expanded body: scrollable preview of the child's turns. Fetched + * lazily on first expand. */ -import { useState } from "react" +import { useMemo, useState } from "react" import { ChevronDown, ChevronRight, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" +import { AgentIcon } from "@/components/agent-icon" import { useDelegatedSubSession } from "@/hooks/use-delegated-sub-session" import { - AGENT_COLORS, AGENT_LABELS, + type AgentType, type ContentBlock, type MessageTurn, } from "@/lib/types" -import { cn } from "@/lib/utils" +import type { ToolCallState } from "@/lib/adapters/ai-elements-adapter" interface Props { parentToolUseId: string + /** Raw JSON arguments the LLM sent to `delegate_to_agent`. Used to + * surface the task and agent_type before the broker's + * DelegationStarted event lands (or when binding never arrives — e.g. + * the wider session was reloaded with an inline child still around). */ + input?: string | null + output?: string | null + errorText?: string | null + state?: ToolCallState +} + +type ParsedInput = { + agentType: AgentType | null + task: string | null + workingDir: string | null + timeoutSeconds: number | null +} + +const KNOWN_AGENT_TYPES: ReadonlySet = new Set([ + "claude_code", + "codex", + "open_code", + "gemini", + "cline", + "open_claw", +]) + +function parseInput(raw: string | null | undefined): ParsedInput { + if (!raw || typeof raw !== "string") { + return { + agentType: null, + task: null, + workingDir: null, + timeoutSeconds: null, + } + } + try { + const obj = JSON.parse(raw) as Record + const at = typeof obj.agent_type === "string" ? obj.agent_type : null + return { + 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, + timeoutSeconds: + typeof obj.timeout_seconds === "number" ? obj.timeout_seconds : null, + } + } catch { + return { + agentType: null, + task: null, + workingDir: null, + timeoutSeconds: null, + } + } } function blocksToText(blocks: ContentBlock[]): string { @@ -45,10 +93,8 @@ function blocksToText(blocks: ContentBlock[]): string { return "" } -function turnSummary(turns: MessageTurn[] | undefined): string { +function lastAssistantText(turns: MessageTurn[] | undefined): string { if (!turns || turns.length === 0) return "" - // Walk back to find the most recent assistant turn with substantive text; - // fall back to the last turn's text if every assistant turn was tool-only. for (let i = turns.length - 1; i >= 0; i--) { if (turns[i].role !== "assistant") continue const text = blocksToText(turns[i].blocks) @@ -62,7 +108,13 @@ function truncate(s: string, max: number): string { return s.slice(0, max).trimEnd() + "…" } -export function DelegatedSubThread({ parentToolUseId }: Props) { +export function DelegatedSubThread({ + parentToolUseId, + input, + output, + errorText, + state, +}: Props) { const t = useTranslations("Folder.chat.delegation") const [expanded, setExpanded] = useState(false) const { binding, detail, loading, error } = useDelegatedSubSession( @@ -70,49 +122,89 @@ export function DelegatedSubThread({ parentToolUseId }: Props) { { enabled: expanded } ) - if (!binding) { + const parsed = useMemo(() => parseInput(input), [input]) + + // Prefer binding-derived state (live event stream) when present, fall + // back to the parent ToolCall's own state/output so we still draw a + // sensible card even if the delegation events never arrived. + const agentType: AgentType | null = binding?.agentType ?? parsed.agentType + const status: "running" | "ok" | "err" = (() => { + if (binding) return binding.status + if (state === "output-error" || errorText) return "err" + if (state === "output-available" && output) return "ok" + return "running" + })() + const errorCode = binding?.errorCode + + const summary = useMemo(() => { + if (detail?.turns?.length) + return truncate(lastAssistantText(detail.turns), 200) + if (status === "err" && errorText) return truncate(errorText, 200) + if (status === "ok" && output) return truncate(output, 200) + return "" + }, [detail, status, errorText, output]) + + // Caller (ToolCallPart) already guarantees this is a `delegate_to_agent` + // tool, but a snapshot replay with an empty/unparseable input AND no live + // binding has no useful card to draw — fall through to the standard + // renderer instead of showing an "unknown sub-agent" stub. Placed AFTER + // all hooks so the hook order stays stable on re-render. + if (!binding && !parsed.agentType && !parsed.task) { return null } - const summary = truncate(turnSummary(detail?.turns), 120) - const turnCount = detail?.turns?.length ?? 0 - return (
{expanded && ( -
+
+ {!binding && status === "running" && ( +
{t("waitingForChild")}
+ )} {loading && (
@@ -127,7 +219,7 @@ export function DelegatedSubThread({ parentToolUseId }: Props) { {!loading && !error && detail && ( )} - {!loading && !error && !detail && ( + {!loading && !error && !detail && binding && (
{t("noDetail")}
)}
@@ -143,13 +235,10 @@ function StatusBadge({ status: "running" | "ok" | "err" errorCode?: string }) { - // next-intl's template-literal-typed t() blows up on dynamic keys, so - // every label is fetched with a static key string. The known error codes - // mirror the Rust `DelegationError` taxonomy. const t = useTranslations("Folder.chat.delegation.status") if (status === "running") { return ( - + {t("running")} @@ -157,14 +246,14 @@ function StatusBadge({ } if (status === "ok") { return ( - + {t("ok")} ) } return ( @@ -253,6 +342,5 @@ function BlockLine({ block }: { block: ContentBlock }) {
) } - // image / image_generation — silently omitted in preview return null } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 35b91abdf..aa92ab2c4 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "مفوّض", + "unknownAgent": "وكيل فرعي", + "waitingForChild": "في انتظار بدء الوكيل الفرعي…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9b4fce3aa..7b08559ee 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "delegiert", + "unknownAgent": "Sub-Agent", + "waitingForChild": "Warte auf den Start des Kind-Agenten…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8b82063f2..65f2833a2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "delegated", + "unknownAgent": "Sub-agent", + "waitingForChild": "Waiting for the child agent to start…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5a9c3c3fd..435d1c309 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "delegado", + "unknownAgent": "Sub-agente", + "waitingForChild": "Esperando a que el agente hijo arranque…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 34f642d24..185dccc94 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "délégué", + "unknownAgent": "Sous-agent", + "waitingForChild": "En attente du démarrage du sous-agent…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 23650b8ef..570103b65 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "委任済み", + "unknownAgent": "サブエージェント", + "waitingForChild": "サブエージェントの起動を待機中…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 31b61bbb8..06a78d1dc 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "위임됨", + "unknownAgent": "하위 에이전트", + "waitingForChild": "하위 에이전트의 시작을 기다리는 중…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 377050577..91626d4e4 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1857,6 +1857,9 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", + "delegatedLabel": "delegado", + "unknownAgent": "Subagente", + "waitingForChild": "Aguardando o início do agente filho…", "status": { "running": "running", "ok": "done", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index a7cb0dd8d..42f43eeee 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1857,6 +1857,9 @@ "loading": "正在加载子会话…", "loadFailed": "加载子会话失败:{detail}", "noDetail": "暂无详情。", + "delegatedLabel": "已委托", + "unknownAgent": "子智能体", + "waitingForChild": "等待子智能体启动…", "status": { "running": "运行中", "ok": "完成", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 2ff3a2dcf..3068b5878 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1857,6 +1857,9 @@ "loading": "正在載入子會話…", "loadFailed": "載入子會話失敗:{detail}", "noDetail": "暫無詳情。", + "delegatedLabel": "已委託", + "unknownAgent": "子智慧體", + "waitingForChild": "等待子智慧體啟動…", "status": { "running": "執行中", "ok": "完成", diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 9724f607c..e8bde2531 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -55,6 +55,10 @@ const EXACT_TOOL_NAME_ALIASES: Record = { close_agent: "task", update_plan: "task", request_user_input: "question", + // codeg multi-agent delegation MCP tool (varies by server prefix) + delegate_to_agent: "delegate_to_agent", + "mcp__codeg-delegate__delegate_to_agent": "delegate_to_agent", + mcp__codeg__delegate_to_agent: "delegate_to_agent", // OpenCode delegate_task: "task", call_omo_agent: "agent", @@ -256,6 +260,12 @@ export function normalizeToolName(toolName: string): string { const alias = EXACT_TOOL_NAME_ALIASES[canonical] if (alias) return alias + // Multi-agent delegation MCP tool. The MCP server prefix + // (`mcp____`) varies — codeg-delegate today, possibly renamed + // tomorrow. Match the suffix so any server name lands on the same + // canonical name as the in-tree alias. + if (canonical.endsWith("__delegate_to_agent")) return "delegate_to_agent" + const freeform = inferFromFreeformName(trimmed) if (freeform) return freeform From f82144268b0f0236aeeada321fcecfb92383be67 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 07:32:48 +0800 Subject: [PATCH 11/29] refactor(delegation): standalone card, expanded-only outcome with markdown * tool-kind-classifier: `isAgentLikeToolName` now also matches `delegate_to_agent` and `mcp____delegate_to_agent`, so the delegation card breaks the tool-call run instead of folding into a consecutive-tool-group capsule. * DelegatedSubThread: drop the shadow, remove the in-header outcome summary line (collapsed card now shows only agent + task), and parse the broker's `{kind, text, code, message}` outcome so the expanded body renders the actual `text` (or `message`) as markdown via MessageResponse instead of a raw JSON string. Sub-thread turn bodies also render markdown now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../message/delegated-sub-thread.test.tsx | 91 +++++++++++- .../message/delegated-sub-thread.tsx | 138 +++++++++++++----- src/lib/adapters/tool-kind-classifier.ts | 13 +- 3 files changed, 195 insertions(+), 47 deletions(-) diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index 16159d00f..b9b54a845 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -10,6 +10,33 @@ vi.mock("@/hooks/use-delegated-sub-session", () => ({ useDelegatedSubSession: vi.fn(), })) +// MessageResponse pulls in workspace context + active folder hooks that +// aren't available in this test's shallow render. We only care that the +// component shows markdown text — render an h1 for fenced headers + the +// raw rest, no streaming, no link-safety. Anything richer is covered by +// MessageResponse's own tests. +vi.mock("@/components/ai-elements/message", () => ({ + MessageResponse: ({ children }: { children: string }) => { + const text = typeof children === "string" ? children : String(children) + const lines = text.split("\n").filter((l) => l.trim().length > 0) + return ( +
+ {lines.map((line, i) => { + const heading = line.match(/^(#+)\s+(.*)$/) + if (heading) { + const level = heading[1].length + const body = heading[2] + if (level === 1) return

{body}

+ if (level === 2) return

{body}

+ return

{body}

+ } + return

{line}

+ })} +
+ ) + }, +})) + const { useDelegatedSubSession } = await import("@/hooks/use-delegated-sub-session") const mockedHook = vi.mocked(useDelegatedSubSession) @@ -93,7 +120,60 @@ describe("DelegatedSubThread", () => { expect(screen.getByText("timeout")).toBeInTheDocument() }) - it("toggles the body open and shows the last assistant text as summary", () => { + it("collapsed card does NOT render the outcome — only the toggle reveals it", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "ok" }), + detail: null, + loading: false, + error: null, + }) + const output = JSON.stringify({ + kind: "ok", + text: "# Result\n\nAll good.", + child_conversation_id: 99, + }) + renderWithIntl( + + ) + expect(screen.queryByText(/All good\./)).not.toBeInTheDocument() + // Markdown header sticks an

inside the body — find via heading role. + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText(/All good\./)).toBeInTheDocument() + // Heading was extracted, not rendered as literal "# Result". + expect(screen.queryByText(/^# Result/)).not.toBeInTheDocument() + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Result" + ) + }) + + it("renders an error outcome from the broker as a destructive block", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "err", errorCode: "timeout" }), + detail: null, + loading: false, + error: null, + }) + const output = JSON.stringify({ + kind: "err", + code: "timeout", + message: "Child timed out after 30s", + }) + renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText(/Child timed out after 30s/)).toBeInTheDocument() + }) + + it("renders sub-session turns with markdown when detail is available", () => { mockedHook.mockReturnValue({ binding: bindingOf({ status: "ok" }), detail: { @@ -129,12 +209,11 @@ describe("DelegatedSubThread", () => { error: null, }) renderWithIntl() - // Summary line in the header shows the assistant's last text. - expect(screen.getByText("delegated answer body")).toBeInTheDocument() - const toggle = screen.getByRole("button") - fireEvent.click(toggle) - // Once expanded the sub-thread renders the role label. + // Collapsed card no longer surfaces the assistant's text in the header. + expect(screen.queryByText("delegated answer body")).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button")) expect(screen.getByText("Assistant")).toBeInTheDocument() expect(screen.getByText("User")).toBeInTheDocument() + expect(screen.getByText("delegated answer body")).toBeInTheDocument() }) }) diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index aab984ef9..85d3b0e24 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" import { AgentIcon } from "@/components/agent-icon" +import { MessageResponse } from "@/components/ai-elements/message" import { useDelegatedSubSession } from "@/hooks/use-delegated-sub-session" import { AGENT_LABELS, @@ -85,27 +86,50 @@ function parseInput(raw: string | null | undefined): ParsedInput { } } -function blocksToText(blocks: ContentBlock[]): string { - for (const b of blocks) { - if (b.type === "text" && b.text.trim().length > 0) return b.text - if (b.type === "thinking" && b.text.trim().length > 0) return b.text - } - return "" -} - -function lastAssistantText(turns: MessageTurn[] | undefined): string { - if (!turns || turns.length === 0) return "" - for (let i = turns.length - 1; i >= 0; i--) { - if (turns[i].role !== "assistant") continue - const text = blocksToText(turns[i].blocks) - if (text) return text +/** + * Best-effort extraction of human-readable result text from the + * `delegate_to_agent` MCP tool's output. The broker's wire shape is + * { kind: "ok", text: "...", child_conversation_id, ... } + * { kind: "err", code: "...", message: "..." } + * but the surrounding tool-call layer may JSON-stringify it OR pass it + * through verbatim. Try the structured shape first; fall back to the + * raw string for plain-text outputs. + */ +function parseDelegationOutcome(raw: string | null | undefined): { + text: string + isError: boolean +} | null { + if (!raw || typeof raw !== "string") return null + const trimmed = raw.trim() + if (!trimmed) return null + try { + const v = JSON.parse(trimmed) as unknown + if (v && typeof v === "object" && !Array.isArray(v)) { + const obj = v as Record + const kind = typeof obj.kind === "string" ? obj.kind : null + if (kind === "ok") { + const text = typeof obj.text === "string" ? obj.text : "" + return { text, isError: false } + } + if (kind === "err") { + const message = typeof obj.message === "string" ? obj.message : "" + const code = typeof obj.code === "string" ? obj.code : "" + return { + text: message || code || "Delegation failed.", + isError: true, + } + } + // Other JSON shapes — pretty-print so we don't surface raw braces. + return { + text: "```json\n" + JSON.stringify(v, null, 2) + "\n```", + isError: false, + } + } + // JSON-parsed primitive — render directly. + return { text: String(v), isError: false } + } catch { + return { text: trimmed, isError: false } } - return blocksToText(turns[turns.length - 1].blocks) -} - -function truncate(s: string, max: number): string { - if (s.length <= max) return s - return s.slice(0, max).trimEnd() + "…" } export function DelegatedSubThread({ @@ -136,13 +160,16 @@ export function DelegatedSubThread({ })() const errorCode = binding?.errorCode - const summary = useMemo(() => { - if (detail?.turns?.length) - return truncate(lastAssistantText(detail.turns), 200) - if (status === "err" && errorText) return truncate(errorText, 200) - if (status === "ok" && output) return truncate(output, 200) - return "" - }, [detail, status, errorText, output]) + // Parse the broker's structured outcome out of the raw tool output so + // the expanded body can render markdown text instead of `{"kind":"ok", + // "text":"..."}` JSON. Falls back to errorText when the tool errored. + const outcome = useMemo(() => { + if (errorText) { + const parsed = parseDelegationOutcome(errorText) + if (parsed) return { ...parsed, isError: true } + } + return parseDelegationOutcome(output) + }, [output, errorText]) // Caller (ToolCallPart) already guarantees this is a `delegate_to_agent` // tool, but a snapshot replay with an empty/unparseable input AND no live @@ -156,7 +183,7 @@ export function DelegatedSubThread({ return (
{expanded ? ( @@ -201,7 +223,7 @@ export function DelegatedSubThread({ {expanded && ( -
+
{!binding && status === "running" && (
{t("waitingForChild")}
)} @@ -216,18 +238,54 @@ export function DelegatedSubThread({ {t("loadFailed", { detail: error })}
)} - {!loading && !error && detail && ( + {!loading && !error && detail && detail.turns.length > 0 && ( )} - {!loading && !error && !detail && binding && ( -
{t("noDetail")}
+ {outcome && outcome.text && ( + )} + {!loading && + !error && + !outcome && + (!detail || detail.turns.length === 0) && + binding && ( +
{t("noDetail")}
+ )}
)}

) } +function DelegationOutcomeBlock({ + text, + isError, +}: { + text: string + isError: boolean +}) { + return ( +
+
+ {text} +
+
+ ) +} + function StatusBadge({ status, errorCode, @@ -317,11 +375,15 @@ function TurnRow({ turn }: { turn: MessageTurn }) { function BlockLine({ block }: { block: ContentBlock }) { if (block.type === "text") { + if (block.text.trim().length === 0) return null return ( -
{block.text}
+
+ {block.text} +
) } if (block.type === "thinking") { + if (block.text.trim().length === 0) return null return (
{block.text} diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index f59728000..32088584e 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -25,12 +25,19 @@ export const TOOL_KIND_ORDER: ToolKindLabel[] = [ /** * Identify agent-like tool calls that own their own card-style rendering - * (e.g. AgentToolCallPart). These should not be folded into a tool-group; - * they each break the run and render standalone. + * (e.g. AgentToolCallPart, DelegatedSubThread). These should not be folded + * into a tool-group; they each break the run and render standalone. + * + * The delegation MCP tool comes through with a varying server-prefix + * (`mcp____delegate_to_agent`), so we match the suffix to catch + * any binding the user configures. */ export function isAgentLikeToolName(toolName: string): boolean { const name = toolName.toLowerCase().trim() - return name === "agent" + if (name === "agent") return true + if (name === "delegate_to_agent") return true + if (name.endsWith("__delegate_to_agent")) return true + return false } export function classifyToolKind(toolName: string): ToolKindLabel { From c92e6085cd3524d2c9ce44420548020dbeac7440 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 07:43:19 +0800 Subject: [PATCH 12/29] refactor(delegation): tighter card header + outcome-first expanded body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Drop the static "· delegated" label — it was the only decoration on the header row and there's no other type/status to disambiguate against, so it added visual noise without information. * Remove the inner bordered/tinted box around the parsed outcome. Markdown body now sits directly under the divider; error variants use the destructive text color instead of a frame. * Rework the expanded-body state machine so the rendered content prioritizes what's already in hand: child detail turns → parsed outcome from the parent's tool_result → fetch in flight → "still running and no output yet". Previously the body keyed off the live delegation binding, so when the DelegationStarted event raced the MCP round-trip the body stayed stuck on "Waiting for the child agent to start…" even though the broker had returned text. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../message/delegated-sub-thread.test.tsx | 32 ++++- .../message/delegated-sub-thread.tsx | 123 ++++++++++++------ 2 files changed, 111 insertions(+), 44 deletions(-) diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index b9b54a845..b9cb8ac1a 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -88,7 +88,6 @@ describe("DelegatedSubThread", () => { // present in the visible card header. expect(screen.getAllByText("Codex").length).toBeGreaterThan(0) expect(screen.getByText("running")).toBeInTheDocument() - expect(screen.getByText("· delegated")).toBeInTheDocument() // collapsed by default — sub-thread body not present expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() }) @@ -150,6 +149,37 @@ describe("DelegatedSubThread", () => { ) }) + it("when the delegation binding never arrives but the tool output did, the expanded body shows the outcome — not 'waiting for child'", () => { + mockedHook.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + const inputJson = JSON.stringify({ + agent_type: "codex", + task: "test the build", + }) + const outputJson = JSON.stringify({ + kind: "ok", + text: "Build succeeded.", + child_conversation_id: 99, + }) + renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("Build succeeded.")).toBeInTheDocument() + expect( + screen.queryByText(/Waiting for the child agent to start/) + ).not.toBeInTheDocument() + }) + it("renders an error outcome from the broker as a destructive block", () => { mockedHook.mockReturnValue({ binding: bindingOf({ status: "err", errorCode: "timeout" }), diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 85d3b0e24..4b37dd924 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -203,9 +203,6 @@ export function DelegatedSubThread({ {agentType ? AGENT_LABELS[agentType] : t("unknownAgent")} - - · {t("delegatedLabel")} -
{parsed.task && ( @@ -224,43 +221,89 @@ export function DelegatedSubThread({ {expanded && (
- {!binding && status === "running" && ( -
{t("waitingForChild")}
- )} - {loading && ( -
- - {t("loading")} -
- )} - {error && ( -
- {t("loadFailed", { detail: error })} -
- )} - {!loading && !error && detail && detail.turns.length > 0 && ( - - )} - {outcome && outcome.text && ( - - )} - {!loading && - !error && - !outcome && - (!detail || detail.turns.length === 0) && - binding && ( -
{t("noDetail")}
- )} + t("loadFailed", { detail: detailMsg })} + tNoDetail={t("noDetail")} + />
)}
) } -function DelegationOutcomeBlock({ +function ExpandedBody({ + status, + loading, + error, + detail, + outcome, + tWaitingForChild, + tLoading, + tLoadFailed, + tNoDetail, +}: { + status: "running" | "ok" | "err" + loading: boolean + error: string | null + detail: { turns: MessageTurn[] } | null + outcome: { text: string; isError: boolean } | null + tWaitingForChild: string + tLoading: string + tLoadFailed: (detail: string) => string + tNoDetail: string +}) { + const hasTurns = !!detail && detail.turns.length > 0 + const hasOutcome = !!outcome && outcome.text.length > 0 + + // Priority: + // 1. detail turns from the child conversation (richest view) + // 2. parsed outcome from the parent's tool_result (lighter — only the + // final assistant text or the failure message, but always available + // the moment the broker returns even if the live binding never + // reached the UI) + // 3. fetch in flight (loading spinner) + // 4. fetch failed (error) + // 5. still running and the parent ToolCall hasn't produced output yet + // (the "waiting" spinner) + // 6. completed but nothing to show (noDetail) + if (hasTurns) { + return + } + if (hasOutcome) { + return ( + + ) + } + if (loading) { + return ( +
+ + {tLoading} +
+ ) + } + if (error) { + return
{tLoadFailed(error)}
+ } + if (status === "running") { + return ( +
+ + {tWaitingForChild} +
+ ) + } + return
{tNoDetail}
+} + +function DelegationOutcomeText({ text, isError, }: { @@ -271,17 +314,11 @@ function DelegationOutcomeBlock({
-
- {text} -
+ {text}
) } From 6463c8b785d6fd751224e59855d4f14a61ae30a7 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 08:46:44 +0800 Subject: [PATCH 13/29] fix(delegation): recognize delegation regardless of host title rephrasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle's `is_delegation_tool_title` and the chat-channel relay's `is_delegation_title` only matched the exact MCP method name. ACP `title` is free-form text the host agent composes; in practice Codex and Claude Code prefix it (`mcp__codeg-delegate__delegate_to_agent`, `Run mcp__…`) and other hosts rephrase it entirely (`Delegate to codex`). When neither literal matched, the lifecycle never registered the parent tool_call_id into the broker's pending queue → the broker fell back to a UUID placeholder for `parent_tool_use_id` → the frontend binding indexed by the real `part.toolCallId` never resolved → the expanded card sat on "Waiting for the child agent to start…" indefinitely. * lifecycle.rs: rename to `is_delegation_invocation` and match by substring on the normalized title OR by raw_input shape (presence of both `agent_type` and `task` string fields). raw_input is a near-zero false-positive signal because that pair only co-occurs on the delegate_to_agent schema. * chat-channel relay: same substring widening on its local `is_delegation_title` (it already had a raw_input fallback for the completion side). * card status: drop the `&& output` guard on the output-available branch so the card flips to "ok" the moment the tool reports completed — even if `output` is empty / not yet joined — instead of staying on "running" and showing the waiting line. * i18n: drop the now-unused `delegatedLabel` key from all 10 locales. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/acp/lifecycle.rs | 90 ++++++++++++++----- .../chat_channel/session_event_subscriber.rs | 13 ++- .../message/delegated-sub-thread.test.tsx | 27 ++++++ .../message/delegated-sub-thread.tsx | 2 +- src/i18n/messages/ar.json | 1 - src/i18n/messages/de.json | 1 - src/i18n/messages/en.json | 1 - src/i18n/messages/es.json | 1 - src/i18n/messages/fr.json | 1 - src/i18n/messages/ja.json | 1 - src/i18n/messages/ko.json | 1 - src/i18n/messages/pt.json | 1 - src/i18n/messages/zh-CN.json | 1 - src/i18n/messages/zh-TW.json | 1 - 14 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 700b471bc..544b4ee2a 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -137,14 +137,24 @@ pub(crate) async fn handle_event( ) -> Result<(), DbError> { match &envelope.payload { AcpEvent::ToolCall { - tool_call_id, title, .. + tool_call_id, + title, + raw_input, + .. } => { // MCP clients don't reliably populate `_meta.tool_use_id`, so we // capture every parent-side `delegate_to_agent` tool_call_id // here. The broker pops the most recent one when the matching // MCP round-trip arrives. See [`DelegationBroker::register_pending_tool_call`]. + // + // ACP `title` is a free-form human-readable string the agent + // composes from the tool name (Codex emits the bare MCP method, + // Claude Code emits "Run ", others phrase it as + // "Delegate to "). Pair the title match with a raw_input + // shape check so we don't miss a delegation just because the + // host re-phrased the title. if let Some(b) = broker { - if is_delegation_tool_title(title) { + if is_delegation_invocation(title, raw_input.as_deref()) { b.register_pending_tool_call( &envelope.connection_id, tool_call_id.clone(), @@ -421,39 +431,77 @@ async fn forward_disconnect_to_broker(broker: &DelegationBroker, connection_id: broker.cancel_by_child_connection(connection_id).await; } -/// True when the ACP `tool_call.title` looks like an invocation of the -/// `delegate_to_agent` MCP tool. Matches both the bare schema name and the -/// `mcp____delegate_to_agent` prefix Codex/Claude Code emit. -fn is_delegation_tool_title(title: &str) -> bool { - let lower = title.to_ascii_lowercase(); - let normalized = lower.replace([' ', '-'], "_"); - normalized == "delegate_to_agent" || normalized.ends_with("__delegate_to_agent") +/// True when the ACP `tool_call` smells like an invocation of the +/// `delegate_to_agent` MCP tool. Defensive on both inputs because the host +/// agent gets to decide both fields: +/// +/// * `title` is a free-form human-readable string the host composes. Some +/// hosts copy the MCP method verbatim (`mcp__codeg-delegate__delegate_to_agent`), +/// some prefix it with a verb (`Run mcp__…__delegate_to_agent`), some +/// rephrase it (`Delegate to codex`). We match by substring so any +/// form containing `delegate_to_agent` is captured. +/// * `raw_input` is the JSON arg blob the agent sent to the MCP server. The +/// `delegate_to_agent` schema requires `agent_type` AND `task`; presence +/// of both is a near-zero false-positive shape check that catches any +/// host that mangles the title beyond recognition. +fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { + let normalized_title = title.to_ascii_lowercase().replace([' ', '-'], "_"); + if normalized_title.contains("delegate_to_agent") { + return true; + } + if let Some(raw) = raw_input { + if let Ok(v) = serde_json::from_str::(raw) { + if let Some(obj) = v.as_object() { + let has_task = obj.get("task").and_then(|t| t.as_str()).is_some(); + let has_agent_type = obj.get("agent_type").and_then(|a| a.as_str()).is_some(); + if has_task && has_agent_type { + return true; + } + } + } + } + false } #[cfg(test)] mod delegation_title_tests { - use super::is_delegation_tool_title; + use super::is_delegation_invocation; #[test] - fn matches_bare_name() { - assert!(is_delegation_tool_title("delegate_to_agent")); - assert!(is_delegation_tool_title("Delegate To Agent")); - assert!(is_delegation_tool_title("delegate-to-agent")); + fn matches_bare_method_in_title() { + assert!(is_delegation_invocation("delegate_to_agent", None)); + assert!(is_delegation_invocation("Delegate To Agent", None)); + assert!(is_delegation_invocation("delegate-to-agent", None)); } #[test] - fn matches_mcp_prefixed_name() { - assert!(is_delegation_tool_title( - "mcp__codeg-delegate__delegate_to_agent" + fn matches_mcp_prefixed_method_in_title() { + assert!(is_delegation_invocation( + "mcp__codeg-delegate__delegate_to_agent", + None + )); + assert!(is_delegation_invocation( + "Run mcp__codeg__delegate_to_agent", + None )); - assert!(is_delegation_tool_title("mcp__codeg__delegate_to_agent")); + } + + #[test] + fn matches_via_raw_input_shape_when_title_is_unrecognized() { + let raw = r#"{"agent_type":"codex","task":"smoke test"}"#; + assert!(is_delegation_invocation("Delegate to codex", Some(raw))); + assert!(is_delegation_invocation("anything", Some(raw))); } #[test] fn rejects_unrelated_tools() { - assert!(!is_delegation_tool_title("write")); - assert!(!is_delegation_tool_title("agent")); - assert!(!is_delegation_tool_title("delegate_other_thing")); + assert!(!is_delegation_invocation("write", None)); + assert!(!is_delegation_invocation("agent", None)); + assert!(!is_delegation_invocation("delegate_other_thing", None)); + assert!(!is_delegation_invocation( + "write", + Some(r#"{"path":"/tmp/x","content":"y"}"#) + )); } } diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index a1600b3b3..7cb0b5514 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -721,11 +721,16 @@ fn truncate_str(s: &str, max: usize) -> String { } } -/// Title-side match for `delegate_to_agent`. Matches both the literal MCP -/// tool name and the human-readable title agents sometimes substitute. +/// Title-side match for `delegate_to_agent`. Title is free-form text the +/// host agent composes; some hosts copy the bare MCP method, some prefix +/// it with `mcp____`, some rephrase it. Match by substring so any +/// of those forms get the delegation-announcement path. The completion- +/// side callsite already pairs this with a raw_input shape check, so a +/// rare false-positive here just sends one announce message that gets +/// overwritten by the completion's actual outcome. fn is_delegation_title(title: &str) -> bool { let normalized = title.to_lowercase().replace([' ', '-'], "_"); - normalized == "delegate_to_agent" + normalized.contains("delegate_to_agent") } /// Pull `agent_type` out of the raw_input JSON (e.g. `{"agent_type":"codex", @@ -791,6 +796,8 @@ mod delegation_relay_tests { assert!(is_delegation_title("delegate_to_agent")); assert!(is_delegation_title("Delegate To Agent")); assert!(is_delegation_title("delegate-to-agent")); + assert!(is_delegation_title("mcp__codeg-delegate__delegate_to_agent")); + assert!(is_delegation_title("Run mcp__codeg__delegate_to_agent")); assert!(!is_delegation_title("agent")); assert!(!is_delegation_title("write")); } diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index b9cb8ac1a..871bdcdf2 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -180,6 +180,33 @@ describe("DelegatedSubThread", () => { ).not.toBeInTheDocument() }) + it("does NOT show the 'waiting for child' line once the tool reached output-available, even if output is an empty string", () => { + mockedHook.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + const inputJson = JSON.stringify({ + agent_type: "codex", + task: "noop", + }) + renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + expect( + screen.queryByText(/Waiting for the child agent to start/) + ).not.toBeInTheDocument() + // Falls back to the "no detail" copy instead of a misleading spinner. + expect(screen.getByText(/No detail available yet/)).toBeInTheDocument() + }) + it("renders an error outcome from the broker as a destructive block", () => { mockedHook.mockReturnValue({ binding: bindingOf({ status: "err", errorCode: "timeout" }), diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 4b37dd924..146ee6659 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -155,7 +155,7 @@ export function DelegatedSubThread({ const status: "running" | "ok" | "err" = (() => { if (binding) return binding.status if (state === "output-error" || errorText) return "err" - if (state === "output-available" && output) return "ok" + if (state === "output-available") return "ok" return "running" })() const errorCode = binding?.errorCode diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index aa92ab2c4..2fdaffbd3 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "مفوّض", "unknownAgent": "وكيل فرعي", "waitingForChild": "في انتظار بدء الوكيل الفرعي…", "status": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 7b08559ee..3642004f4 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "delegiert", "unknownAgent": "Sub-Agent", "waitingForChild": "Warte auf den Start des Kind-Agenten…", "status": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 65f2833a2..449c39303 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "delegated", "unknownAgent": "Sub-agent", "waitingForChild": "Waiting for the child agent to start…", "status": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 435d1c309..fd546dad0 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "delegado", "unknownAgent": "Sub-agente", "waitingForChild": "Esperando a que el agente hijo arranque…", "status": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 185dccc94..75b304913 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "délégué", "unknownAgent": "Sous-agent", "waitingForChild": "En attente du démarrage du sous-agent…", "status": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 570103b65..e51d66e96 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "委任済み", "unknownAgent": "サブエージェント", "waitingForChild": "サブエージェントの起動を待機中…", "status": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 06a78d1dc..ef8ef855c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "위임됨", "unknownAgent": "하위 에이전트", "waitingForChild": "하위 에이전트의 시작을 기다리는 중…", "status": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 91626d4e4..40c8a516e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1857,7 +1857,6 @@ "loading": "Loading sub-session…", "loadFailed": "Failed to load sub-session: {detail}", "noDetail": "No detail available yet.", - "delegatedLabel": "delegado", "unknownAgent": "Subagente", "waitingForChild": "Aguardando o início do agente filho…", "status": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 42f43eeee..9cea388a4 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1857,7 +1857,6 @@ "loading": "正在加载子会话…", "loadFailed": "加载子会话失败:{detail}", "noDetail": "暂无详情。", - "delegatedLabel": "已委托", "unknownAgent": "子智能体", "waitingForChild": "等待子智能体启动…", "status": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 3068b5878..46ec6a21b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1857,7 +1857,6 @@ "loading": "正在載入子會話…", "loadFailed": "載入子會話失敗:{detail}", "noDetail": "暫無詳情。", - "delegatedLabel": "已委託", "unknownAgent": "子智慧體", "waitingForChild": "等待子智慧體啟動…", "status": { From 01b9a0a513079efd91631562c4475aa46c7adc14 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 23 May 2026 16:46:31 +0800 Subject: [PATCH 14/29] feat(delegation): live append-only result stream + snapshot rebinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline DelegatedSubThread now stitches every child text segment append-only under a persistent "sub-agent running…" indicator, filtering out tool calls and thinking blocks so the parent UI only ever shows the visible result — later segments never overwrite earlier ones. Parent ToolUse snapshots carry meta.codeg.delegation (new meta_writer module + meta field threaded through models, parsers, ACP types), so a post-refresh remount rebinds to the child connection without depending on the live event surviving. Broker matches each MCP round-trip to the parent's real ACP tool_call_id via a pending queue with a brief race-wait, and the lifecycle dispatcher forwards ToolCall events so that queue actually fills — the card always binds to the real tool_use_id rather than falling back to a synthetic one. i18n consolidates the delegation strings around subAgentRunning across all 10 locales. --- src-tauri/src/acp/delegation/broker.rs | 447 +++++++++++++++++- src-tauri/src/acp/delegation/meta_writer.rs | 251 ++++++++++ src-tauri/src/acp/delegation/mod.rs | 1 + src-tauri/src/acp/lifecycle.rs | 21 + src-tauri/src/app_state.rs | 14 +- src-tauri/src/models/message.rs | 13 + src-tauri/src/parsers/claude.rs | 3 + src-tauri/src/parsers/cline.rs | 1 + src-tauri/src/parsers/codex.rs | 2 + src-tauri/src/parsers/gemini.rs | 1 + src-tauri/src/parsers/openclaw.rs | 1 + src-tauri/src/parsers/opencode.rs | 2 + .../message/content-parts-renderer.tsx | 1 + .../message/delegated-sub-thread.test.tsx | 333 ++++++++++++- .../message/delegated-sub-thread.tsx | 393 ++++++++++----- src/contexts/acp-connections-context.tsx | 228 ++++++++- src/contexts/conversation-runtime-context.tsx | 6 + src/contexts/delegation-context.tsx | 72 ++- src/hooks/use-delegated-sub-session.ts | 11 +- src/i18n/messages/ar.json | 5 +- src/i18n/messages/de.json | 5 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 5 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/ja.json | 5 +- src/i18n/messages/ko.json | 5 +- src/i18n/messages/pt.json | 5 +- src/i18n/messages/zh-CN.json | 5 +- src/i18n/messages/zh-TW.json | 5 +- src/lib/adapters/ai-elements-adapter.ts | 14 +- src/lib/types.ts | 8 + 31 files changed, 1679 insertions(+), 194 deletions(-) create mode 100644 src-tauri/src/acp/delegation/meta_writer.rs diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index a92421f48..03e04c504 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -31,6 +31,9 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::{oneshot, Mutex}; +use crate::acp::delegation::meta_writer::{ + build_delegation_meta, is_synthetic_parent_tool_use_id, DelegationMetaWriter, NoopMetaWriter, +}; use crate::acp::delegation::spawner::{ConnectionSpawner, DelegationLink}; use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationRequest}; @@ -98,6 +101,12 @@ struct PendingToolCalls { pub struct DelegationBroker { spawner: Arc, depth_lookup: Arc, + /// Writer for `meta["codeg.delegation"]` on the parent's active + /// `delegate_to_agent` ToolCallState. Defaults to a no-op so tests + /// that aren't exercising the meta lifecycle don't need to wire + /// anything; production constructs the broker with the + /// `ConnectionManagerMetaWriter` via `with_meta_writer`. + meta_writer: Arc, pending: Arc, pending_tool_calls: Arc, config: Arc>, @@ -107,10 +116,27 @@ impl DelegationBroker { pub fn new( spawner: Arc, depth_lookup: Arc, + ) -> Self { + Self::with_meta_writer( + spawner, + depth_lookup, + Arc::new(NoopMetaWriter) as Arc, + ) + } + + /// Production-grade constructor wiring the broker to a real meta + /// writer (typically `ConnectionManagerMetaWriter`). Tests that + /// observe the meta lifecycle should also use this with a + /// `MockMetaWriter`. + pub fn with_meta_writer( + spawner: Arc, + depth_lookup: Arc, + meta_writer: Arc, ) -> Self { Self { spawner, depth_lookup, + meta_writer, pending: Arc::new(PendingCalls::default()), pending_tool_calls: Arc::new(PendingToolCalls::default()), config: Arc::new(Mutex::new(DelegationConfig::default())), @@ -149,6 +175,42 @@ impl DelegationBroker { id } + /// `take_pending_tool_call` with a brief poll loop. Used by + /// `handle_request` to absorb the inherent race between two parallel + /// arrival paths for the parent's `delegate_to_agent` invocation: + /// + /// * ACP `session/update(tool_call)` → in-process bus → lifecycle + /// dispatcher → `register_pending_tool_call` (fast) + /// * MCP `tools/call` → stdio round-trip → companion server → + /// `handle_request` (slower, but not by much) + /// + /// In practice the ACP path lands first because it's in-process, but + /// the order is not contractually guaranteed. Without this wait, a + /// faster-than-usual MCP delivery would slip past an empty queue and + /// fall back to the synthetic `delegation-` placeholder — which + /// breaks the parent's UI binding because the frontend keys its + /// `parent_tool_use_id` map by the agent's real `tool_call_id`. + /// + /// 100 ms total polling budget (10 attempts × 10 ms) is generous: the + /// observed gap on local dev is well under 5 ms, but headroom protects + /// against busier hosts or slower MCP transports without delaying the + /// no-ACP-id fallback path materially. + async fn claim_pending_tool_call_with_brief_wait( + &self, + parent_connection_id: &str, + ) -> Option { + if let Some(id) = self.take_pending_tool_call(parent_connection_id).await { + return Some(id); + } + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(10)).await; + if let Some(id) = self.take_pending_tool_call(parent_connection_id).await { + return Some(id); + } + } + None + } + /// Forget every pending tool_call id for the given parent. Called when /// the parent connection tears down so stale ids don't bind to a future /// reuse of the same connection_id (UUIDs make that unlikely but cheap @@ -174,13 +236,14 @@ impl DelegationBroker { pub async fn handle_request(&self, mut req: DelegationRequest) -> DelegationOutcome { // MCP clients usually don't populate `_meta.tool_use_id`, so the // listener will pass through an empty string. Best-effort claim the - // most recent ACP-side `tool_call_id` for this parent; otherwise - // mint a placeholder UUID so the rest of the lifecycle still works - // (the only downside of the fallback is the UI not being able to - // attach the sub-thread under the parent's ToolCallBlock). + // most recent ACP-side `tool_call_id` for this parent — with a brief + // poll loop so an MCP round-trip that out-races the in-process ACP + // `session/update` doesn't fall back to a synthetic id (which + // breaks the parent UI's `parent_tool_use_id` binding). Falls back + // to a UUID placeholder only after the wait budget is exhausted. if req.parent_tool_use_id.is_empty() { req.parent_tool_use_id = self - .take_pending_tool_call(&req.parent_connection_id) + .claim_pending_tool_call_with_brief_wait(&req.parent_connection_id) .await .unwrap_or_else(|| format!("delegation-{}", uuid::Uuid::new_v4())); } @@ -270,6 +333,24 @@ impl DelegationBroker { } }; + // --- Mark the parent's tool call as in-flight ------------------------- + // The frontend's DelegationContext seeds its `parent_tool_use_id`-keyed + // binding map from this meta on snapshot replay, so a page refresh + // mid-delegation can reconstruct the child connection / conversation + // ids without depending on the live `delegation_started` event having + // been received. + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "running", + Some(&child_connection_id), + Some(child_conversation_id), + None, + ), + ) + .await; + // --- Register pending + race timeout vs completion -------------------- let (tx, rx) = oneshot::channel(); { @@ -297,6 +378,17 @@ impl DelegationBroker { // The sender was dropped before sending — should not happen in // practice (complete_call always sends before drop), but be defensive. self.pending.inner.lock().await.remove(&call_id); + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("canceled"), + ), + ) + .await; let _ = self.spawner.disconnect(&child_connection_id).await; DelegationOutcome::from_err( DelegationError::Canceled { @@ -307,6 +399,17 @@ impl DelegationBroker { } Err(_) => { // Timeout: cancel in-flight, then disconnect, then return. + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("timeout"), + ), + ) + .await; let _ = self.spawner.cancel(&child_connection_id).await; let _ = self.spawner.disconnect(&child_connection_id).await; self.pending.inner.lock().await.remove(&call_id); @@ -327,16 +430,58 @@ impl DelegationBroker { let entry = self.pending.inner.lock().await.remove(call_id); if let Some(PendingCall { child_connection_id, + child_conversation_id, + parent_connection_id, + parent_tool_use_id, tx, - .. }) = entry { + // Mirror the resolution onto the parent's `delegate_to_agent` + // ToolCallState meta so snapshot recovery after refresh shows + // the final state without depending on the broker's live + // `delegation_completed` event having been received. + let meta = match &outcome { + DelegationOutcome::Ok(_) => build_delegation_meta( + "completed", + Some(&child_connection_id), + Some(child_conversation_id), + None, + ), + DelegationOutcome::Err { code, .. } => build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some(code), + ), + }; + self.write_meta_if_real(&parent_connection_id, &parent_tool_use_id, meta) + .await; // v1 one-shot: always tear down the child. let _ = self.spawner.disconnect(&child_connection_id).await; let _ = tx.send(outcome); } } + /// Internal helper — apply the meta write iff the parent's + /// `tool_use_id` refers to a real ACP `tool_call_id`. The + /// broker-synthesized `"delegation-"` placeholder targets no + /// ToolCallState, so emitting a `ToolCallUpdate` against it would be + /// noise that the frontend would route through `apply_tool_call_update` + /// to a non-existent entry. See `meta_writer::is_synthetic_parent_tool_use_id`. + async fn write_meta_if_real( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + if is_synthetic_parent_tool_use_id(parent_tool_use_id) { + return; + } + self.meta_writer + .write_meta(parent_connection_id, parent_tool_use_id, meta) + .await; + } + /// Resolve the pending delegation whose child matches /// `child_connection_id` with a `canceled` outcome. Used when a child /// session disconnects or errors out without firing a clean @@ -355,6 +500,17 @@ impl DelegationBroker { .collect() }; for entry in drained { + self.write_meta_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&entry.child_connection_id), + Some(entry.child_conversation_id), + Some("canceled"), + ), + ) + .await; let _ = self.spawner.disconnect(&entry.child_connection_id).await; let _ = entry.tx.send(DelegationOutcome::from_err( DelegationError::Canceled { @@ -386,6 +542,20 @@ impl DelegationBroker { .collect() }; for entry in drained { + // Best-effort meta patch so a parent-side snapshot post-cancel + // shows the delegation as failed/canceled rather than stuck + // on the prior "running" mark. + self.write_meta_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&entry.child_connection_id), + Some(entry.child_conversation_id), + Some("canceled"), + ), + ) + .await; let _ = self.spawner.cancel(&entry.child_connection_id).await; let _ = self.spawner.disconnect(&entry.child_connection_id).await; let _ = entry.tx.send(DelegationOutcome::from_err( @@ -804,6 +974,57 @@ mod tests { assert!(matches!(outcome, DelegationOutcome::Ok(_))); } + #[tokio::test] + async fn empty_parent_tool_use_id_claims_pending_arriving_late() { + // Regression: when the parent's ACP `session/update(tool_call)` + // lands at the lifecycle dispatcher AFTER `broker.handle_request` + // already entered the claim phase, the brief poll loop must still + // pick it up rather than falling back to the synthetic UUID. + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-late".into())).await; + mock.queue_send(Ok(13)).await; + let broker = DelegationBroker::new( + mock.clone() as Arc, + shallow_lookup(), + ); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + + // Give the driver time to enter the claim wait loop on an empty + // queue, then register the ACP id (simulates the dispatcher's + // ToolCall handling landing late). + tokio::time::sleep(Duration::from_millis(30)).await; + broker + .register_pending_tool_call("parent-conn", "tu-late".into()) + .await; + + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // The late-arriving ACP id was consumed by the broker — no leftover + // entry. + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "late ok".into(), + child_conversation_id: 13, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + #[tokio::test] async fn empty_parent_tool_use_id_with_no_pending_falls_back_to_uuid() { let mock = Arc::new(MockSpawner::new()); @@ -893,4 +1114,218 @@ mod tests { let outcome = driver.await.unwrap(); assert!(matches!(outcome, DelegationOutcome::Ok(_))); } + + // -- Meta writer lifecycle -------------------------------------------- + + use crate::acp::delegation::meta_writer::mock::MockMetaWriter; + use crate::acp::delegation::meta_writer::DelegationMetaWriter; + + fn broker_with_meta( + mock: Arc, + writer: Arc, + ) -> DelegationBroker { + DelegationBroker::with_meta_writer( + mock as Arc, + shallow_lookup(), + writer as Arc, + ) + } + + #[tokio::test] + async fn meta_writer_records_running_then_completed_on_happy_path() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-real")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 42, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert_eq!(calls.len(), 2); + // First write: running, with child connection + conversation ids. + let first = &calls[0]; + assert_eq!(first.parent_tool_use_id, "pt-real"); + let inner_first = first + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(inner_first.get("status").unwrap().as_str().unwrap(), "running"); + assert_eq!( + inner_first + .get("child_connection_id") + .unwrap() + .as_str() + .unwrap(), + "child-conn-1" + ); + assert_eq!( + inner_first + .get("child_conversation_id") + .unwrap() + .as_i64() + .unwrap(), + 42 + ); + // Second write: completed. + let second = &calls[1]; + let inner_second = second + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!( + inner_second.get("status").unwrap().as_str().unwrap(), + "completed" + ); + } + + #[tokio::test] + async fn meta_writer_records_failed_on_err_outcome() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-2".into())).await; + mock.queue_send(Ok(7)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-err")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::from_err( + DelegationError::Timeout { elapsed_ms: 5_000 }, + Some(7), + ), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert_eq!(calls.len(), 2); + let inner = calls[1] + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "timeout" + ); + } + + #[tokio::test] + async fn meta_writer_records_failed_on_parent_cancel() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-cancel".into())).await; + mock.queue_send(Ok(33)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-pcancel")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_parent("parent-conn").await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Err { .. })); + + let calls = writer.snapshot().await; + // running + canceled + assert_eq!(calls.len(), 2); + let inner = calls[1] + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "canceled" + ); + } + + #[tokio::test] + async fn meta_writer_skipped_for_synthetic_parent_tool_use_id() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-synth".into())).await; + mock.queue_send(Ok(8)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + // Empty `parent_tool_use_id` triggers the broker's UUID fallback — + // `"delegation-"` — which the writer must skip because no + // matching ACP tool_call_id exists. + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 8, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert!( + calls.is_empty(), + "writer should be skipped for synthetic parent_tool_use_id, got {:?}", + calls + ); + } } diff --git a/src-tauri/src/acp/delegation/meta_writer.rs b/src-tauri/src/acp/delegation/meta_writer.rs new file mode 100644 index 000000000..509e00898 --- /dev/null +++ b/src-tauri/src/acp/delegation/meta_writer.rs @@ -0,0 +1,251 @@ +//! `DelegationMetaWriter` — broker capability that attaches the live +//! delegation state onto the parent's active `delegate_to_agent` +//! tool-call. The shape written under `meta["codeg.delegation"]` +//! follows the convention documented at +//! [`crate::acp::session_state::ToolCallState::meta`]. +//! +//! The broker calls this at three lifecycle points: +//! +//! 1. After `send_prompt_linked_for_delegation` returns Ok — sets +//! `status: "running"` with the child's connection / conversation ids. +//! 2. In `complete_call` — sets `status: "completed"` (ok branch) or +//! `status: "failed"` + `error_code` (err branch). +//! 3. In `cancel_by_parent` / `cancel_by_child_connection` — sets +//! `status: "failed"` + `error_code: "canceled"`. +//! +//! Writes are skipped when the broker is operating on a synthetic +//! `parent_tool_use_id` (the `"delegation-*"` UUID fallback) because +//! there's no matching ACP `tool_call_id` to attach meta to. The +//! frontend's snapshot path will still recover via `parseInput(input)`. + +use async_trait::async_trait; +use std::sync::Arc; + +use crate::acp::manager::ConnectionManager; +use crate::acp::types::AcpEvent; +use crate::web::event_bridge::emit_with_state; + +/// Top-level key under which delegation state lives on a tool call's +/// `meta` object. Single source of truth — both the writer and the +/// frontend reader must spell it the same way. +pub const DELEGATION_META_KEY: &str = "codeg.delegation"; + +/// Capability the broker uses to patch `meta["codeg.delegation"]` on +/// the parent connection's active `delegate_to_agent` tool call. +/// +/// Errors are swallowed at the impl boundary: a missing parent +/// connection (e.g. user disconnected mid-delegation) or a stale +/// tool_use_id (e.g. parent turn already wrapped up) must not derail +/// the rest of the broker lifecycle, which still has to disconnect the +/// child and resolve the pending call. +#[async_trait] +pub trait DelegationMetaWriter: Send + Sync { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ); +} + +/// Default writer used when the broker is constructed via the +/// short-form `DelegationBroker::new` (most test callsites). Silently +/// drops every write — the broker's correctness is observable through +/// its outcomes and pending-call accounting, not through meta emits. +#[derive(Default, Clone)] +pub struct NoopMetaWriter; + +#[async_trait] +impl DelegationMetaWriter for NoopMetaWriter { + async fn write_meta( + &self, + _parent_connection_id: &str, + _parent_tool_use_id: &str, + _meta: serde_json::Value, + ) { + } +} + +/// Production impl backed by `ConnectionManager`. Emits an +/// `AcpEvent::ToolCallUpdate` carrying only the `meta` field so the +/// existing `apply_tool_call_update` merge path (partial-update +/// preservation of locations / images / content / etc.) is reused +/// without duplicating the patch logic. +#[derive(Clone)] +pub struct ConnectionManagerMetaWriter { + pub manager: Arc, +} + +#[async_trait] +impl DelegationMetaWriter for ConnectionManagerMetaWriter { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + let Some((state_arc, emitter)) = self + .manager + .get_state_and_emitter(parent_connection_id) + .await + else { + return; + }; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::ToolCallUpdate { + tool_call_id: parent_tool_use_id.to_string(), + title: None, + status: None, + content: None, + raw_input: None, + raw_output: None, + raw_output_append: None, + locations: None, + meta: Some(meta), + images: None, + }, + ) + .await; + } +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock { + use super::*; + use tokio::sync::Mutex; + + /// Records every call so broker tests can assert the meta lifecycle + /// (running → completed/failed) was driven correctly. No-op on the + /// emit side — the broker is the unit under test, not the event + /// fanout. + #[derive(Default)] + pub struct MockMetaWriter { + pub calls: Mutex>, + } + + #[derive(Debug, Clone)] + pub struct MetaWriteCall { + pub parent_connection_id: String, + pub parent_tool_use_id: String, + pub meta: serde_json::Value, + } + + impl MockMetaWriter { + pub fn new() -> Self { + Self::default() + } + + pub async fn snapshot(&self) -> Vec { + self.calls.lock().await.clone() + } + } + + #[async_trait] + impl DelegationMetaWriter for MockMetaWriter { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + self.calls.lock().await.push(MetaWriteCall { + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: parent_tool_use_id.to_string(), + meta, + }); + } + } +} + +/// Helper to construct the canonical `meta["codeg.delegation"]` value. +/// Keeps the schema in one place so the writer impls and the broker +/// callsites can't drift apart on field naming. +pub fn build_delegation_meta( + status: &str, + child_connection_id: Option<&str>, + child_conversation_id: Option, + error_code: Option<&str>, +) -> serde_json::Value { + let mut inner = serde_json::Map::new(); + inner.insert("status".to_string(), serde_json::Value::String(status.to_string())); + if let Some(id) = child_connection_id { + inner.insert( + "child_connection_id".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + if let Some(id) = child_conversation_id { + inner.insert( + "child_conversation_id".to_string(), + serde_json::Value::Number(serde_json::Number::from(id)), + ); + } + if let Some(code) = error_code { + inner.insert( + "error_code".to_string(), + serde_json::Value::String(code.to_string()), + ); + } + let mut outer = serde_json::Map::new(); + outer.insert( + DELEGATION_META_KEY.to_string(), + serde_json::Value::Object(inner), + ); + serde_json::Value::Object(outer) +} + +/// True when the broker handed out a synthetic placeholder +/// `parent_tool_use_id` (no matching ACP tool_call_id exists). Skipping +/// meta writes for these avoids spamming `ToolCallUpdate` events with a +/// tool_call_id that no live `ToolCallState` will ever match. +pub fn is_synthetic_parent_tool_use_id(id: &str) -> bool { + id.starts_with("delegation-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_meta_includes_provided_fields() { + let v = build_delegation_meta("running", Some("conn-1"), Some(42), None); + let inner = v.get(DELEGATION_META_KEY).unwrap().as_object().unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "running"); + assert_eq!( + inner.get("child_connection_id").unwrap().as_str().unwrap(), + "conn-1" + ); + assert_eq!( + inner + .get("child_conversation_id") + .unwrap() + .as_i64() + .unwrap(), + 42 + ); + assert!(inner.get("error_code").is_none()); + } + + #[test] + fn build_meta_with_error_code() { + let v = build_delegation_meta("failed", None, Some(7), Some("timeout")); + let inner = v.get(DELEGATION_META_KEY).unwrap().as_object().unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "timeout" + ); + assert!(inner.get("child_connection_id").is_none()); + } + + #[test] + fn synthetic_id_detection() { + assert!(is_synthetic_parent_tool_use_id( + "delegation-3b4a5c6d-7e8f-90ab-cdef-1234567890ab" + )); + assert!(!is_synthetic_parent_tool_use_id("tu_real_acp_id")); + assert!(!is_synthetic_parent_tool_use_id("")); + } +} diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index ec8585e49..88242d9f9 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -34,6 +34,7 @@ pub mod broker; pub mod companion; pub mod depth; pub mod listener; +pub mod meta_writer; pub mod spawner; pub mod transport; pub mod types; diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 544b4ee2a..de9a00605 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -48,12 +48,18 @@ const WORKER_QUEUE_CAPACITY: usize = 64; /// uninteresting events) means ContentDelta floods can't crowd out a /// TurnComplete in the worker mailbox: only events that may write the DB /// or update the per-connection cache enter the queue. +/// +/// `ToolCall` is in the accept list because the worker's ToolCall arm +/// captures `delegate_to_agent` invocations for the broker's pending +/// tool_call_id queue. ToolCall fires a handful of times per turn (not +/// per-token like ContentDelta), so the queue pressure is bounded. fn is_lifecycle_relevant(event: &AcpEvent) -> bool { matches!( event, AcpEvent::SessionStarted { .. } | AcpEvent::TurnComplete { .. } | AcpEvent::ConversationLinked { .. } + | AcpEvent::ToolCall { .. } | AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected } @@ -1194,6 +1200,21 @@ mod tests { parent_conversation_id: None, parent_tool_use_id: None, })); + // ToolCall must enter the queue so the delegation broker's + // pending tool_call_id capture (see `handle_event`'s ToolCall + // arm) actually runs. + assert!(is_lifecycle_relevant(&AcpEvent::ToolCall { + tool_call_id: "tc-1".into(), + title: "delegate_to_agent".into(), + kind: "other".into(), + status: "pending".into(), + content: None, + raw_input: None, + raw_output: None, + locations: None, + meta: None, + images: None, + })); assert!(is_lifecycle_relevant(&AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected, })); diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index f1234ecb8..ce5148054 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -72,6 +72,9 @@ pub fn build_delegation_stack( use crate::acp::connection::DelegationInjection; use crate::acp::delegation::broker::{ConversationDepthLookup, DbDepthLookup}; use crate::acp::delegation::listener::default_socket_path; + use crate::acp::delegation::meta_writer::{ + ConnectionManagerMetaWriter, DelegationMetaWriter, + }; use crate::acp::delegation::spawner::ConnectionSpawner; use crate::acp::manager::ConnectionManagerSpawner; @@ -80,11 +83,18 @@ pub fn build_delegation_stack( conn: db_conn.clone(), }); let spawner = Arc::new(ConnectionManagerSpawner { - manager: cm_arc, + manager: cm_arc.clone(), db: db_arc.clone(), }) as Arc; let depth_lookup = Arc::new(DbDepthLookup { db: db_arc }) as Arc; - let broker = Arc::new(DelegationBroker::new(spawner, depth_lookup)); + let meta_writer = Arc::new(ConnectionManagerMetaWriter { + manager: cm_arc, + }) as Arc; + let broker = Arc::new(DelegationBroker::with_meta_writer( + spawner, + depth_lookup, + meta_writer, + )); let tokens = Arc::new(TokenRegistry::default()); let socket_path = default_socket_path(&std::env::temp_dir()); diff --git a/src-tauri/src/models/message.rs b/src-tauri/src/models/message.rs index c9d3a6a86..23e7df504 100644 --- a/src-tauri/src/models/message.rs +++ b/src-tauri/src/models/message.rs @@ -103,6 +103,19 @@ pub enum ContentBlock { tool_use_id: Option, tool_name: String, input_preview: Option, + /// ACP extensibility metadata associated with the tool call. The + /// `delegate_to_agent` lifecycle writes + /// `meta["codeg.delegation"] = { status, child_connection_id, + /// child_conversation_id, error_code? }` here so a snapshot or DB + /// re-fetch can re-bind the parent UI to the child conversation + /// without depending on the live event stream having survived. + /// + /// `None` for tool uses without any meta (the agent didn't emit + /// one, or the field predates the meta-on-ToolUse schema change). + /// The shape is intentionally opaque — `serde_json::Value` — + /// because the convention is agent-defined and may grow. + #[serde(default, skip_serializing_if = "Option::is_none")] + meta: Option, }, ToolResult { tool_use_id: Option, diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 40d16c3a9..775deba7f 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -710,6 +710,7 @@ impl ClaudeParser { tool_use_id: Some(synthetic_id), tool_name, input_preview, + meta: None, }); } else { messages.push(UnifiedMessage { @@ -719,6 +720,7 @@ impl ClaudeParser { tool_use_id: Some(synthetic_id), tool_name, input_preview, + meta: None, }], timestamp, usage: None, @@ -1082,6 +1084,7 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec { tool_use_id, tool_name, input_preview, + meta: None, }); } _ => {} diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index 922666966..a6df3dc59 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -558,6 +558,7 @@ fn parse_content_blocks(content: &serde_json::Value) -> Vec { tool_use_id, tool_name, input_preview, + meta: None, }); } "tool_result" => { diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index bc32e4ab5..21b4f9ef1 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -1036,6 +1036,7 @@ impl CodexParser { tool_use_id, tool_name: "Agent".to_string(), input_preview: Some(agent_input.to_string()), + meta: None, }], timestamp, usage: None, @@ -1096,6 +1097,7 @@ impl CodexParser { tool_use_id, tool_name: raw_tool_name.to_string(), input_preview, + meta: None, }], timestamp, usage: None, diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index bcd09d03c..877796c1f 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -540,6 +540,7 @@ impl GeminiParser { tool_use_id: tool_use_id.clone(), tool_name, input_preview, + meta: None, }); let output_preview = Self::result_display_preview(call.get("resultDisplay")) diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index e72a08c3d..7d9d8ff18 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -1016,6 +1016,7 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec { tool_use_id, tool_name, input_preview, + meta: None, }); } _ => {} diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index 6bda97679..533b2487d 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -486,6 +486,7 @@ impl OpenCodeParser { tool_use_id: call_id.clone(), tool_name: "Agent".to_string(), input_preview: Some(agent_input.to_string()), + meta: None, }); let output_preview = state @@ -543,6 +544,7 @@ impl OpenCodeParser { tool_use_id: call_id.clone(), tool_name: raw_tool_name.to_string(), input_preview, + meta: None, }); let output_preview = state diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 391ef9af7..3ca24de9a 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2312,6 +2312,7 @@ const ToolCallPart = memo(function ToolCallPart({ output={part.output ?? null} errorText={part.errorText ?? null} state={part.state} + meta={part.meta ?? null} /> ) } diff --git a/src/components/message/delegated-sub-thread.test.tsx b/src/components/message/delegated-sub-thread.test.tsx index 871bdcdf2..c0bd3a09c 100644 --- a/src/components/message/delegated-sub-thread.test.tsx +++ b/src/components/message/delegated-sub-thread.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { DelegatedSubThread } from "./delegated-sub-thread" import enMessages from "@/i18n/messages/en.json" @@ -10,6 +10,69 @@ vi.mock("@/hooks/use-delegated-sub-session", () => ({ useDelegatedSubSession: vi.fn(), })) +// DelegatedSubThread now reads child live state from the connections +// store (Phase B+E) and routes delegation-child attach/detach through +// the actions context. Tests assert *component* behavior, not provider +// wiring — so we stub the contexts directly here. +const mockFindByParentToolUseId = vi.fn() +const mockAttachDelegationChild = vi.fn() +const mockDetachDelegationChild = vi.fn() +const mockRespondPermission = vi.fn() +let mockChildConnection: unknown = undefined + +vi.mock("@/contexts/delegation-context", async () => { + const actual = await vi.importActual< + typeof import("@/contexts/delegation-context") + >("@/contexts/delegation-context") + return { + ...actual, + useDelegation: () => ({ + findByParentToolUseId: mockFindByParentToolUseId, + findByChildConversationId: vi.fn(), + }), + } +}) + +vi.mock("@/contexts/acp-connections-context", async () => { + const actual = await vi.importActual< + typeof import("@/contexts/acp-connections-context") + >("@/contexts/acp-connections-context") + return { + ...actual, + useAcpActions: () => ({ + // Only the members DelegatedSubThread reads — other actions can + // be omitted because the component never touches them. + attachDelegationChild: mockAttachDelegationChild, + detachDelegationChild: mockDetachDelegationChild, + respondPermission: mockRespondPermission, + }), + useConnectionStore: () => ({ + // Subscribe is a no-op; getConnection returns whatever the test + // configured for the synthetic child entry. + subscribeKey: () => () => {}, + getConnection: () => mockChildConnection, + getActiveKey: () => null, + subscribeActiveKey: () => () => {}, + }), + } +}) + +// PermissionDialog has its own dependency graph (parsePermissionToolCall, +// CodeBlock, UnifiedDiffPreview…). Mock it down to a sentinel so we can +// assert it rendered without booting all of that. +vi.mock("@/components/chat/permission-dialog", () => ({ + PermissionDialog: ({ + permission, + }: { + permission: { request_id: string } | null + }) => + permission ? ( +
+ permission for {permission.request_id} +
+ ) : null, +})) + // MessageResponse pulls in workspace context + active folder hooks that // aren't available in this test's shallow render. We only care that the // component shows markdown text — render an h1 for fenced headers + the @@ -62,6 +125,17 @@ function bindingOf(overrides: Partial): DelegationBinding { } describe("DelegatedSubThread", () => { + beforeEach(() => { + mockFindByParentToolUseId.mockReset() + mockAttachDelegationChild.mockReset() + mockDetachDelegationChild.mockReset() + mockRespondPermission.mockReset() + mockChildConnection = undefined + // Default: no live binding from the in-memory context. Individual + // tests can override per case. + mockFindByParentToolUseId.mockReturnValue(undefined) + }) + it("renders nothing when there's no binding and no parseable input", () => { mockedHook.mockReturnValue({ binding: undefined, @@ -180,7 +254,7 @@ describe("DelegatedSubThread", () => { ).not.toBeInTheDocument() }) - it("does NOT show the 'waiting for child' line once the tool reached output-available, even if output is an empty string", () => { + it("does NOT show the running indicator once the tool reached output-available, even if output is an empty string", () => { mockedHook.mockReturnValue({ binding: undefined, detail: null, @@ -200,10 +274,8 @@ describe("DelegatedSubThread", () => { /> ) fireEvent.click(screen.getByRole("button")) - expect( - screen.queryByText(/Waiting for the child agent to start/) - ).not.toBeInTheDocument() - // Falls back to the "no detail" copy instead of a misleading spinner. + expect(screen.queryByText(/Sub-agent running/)).not.toBeInTheDocument() + // Falls back to the "no detail" copy instead of a misleading indicator. expect(screen.getByText(/No detail available yet/)).toBeInTheDocument() }) @@ -230,7 +302,11 @@ describe("DelegatedSubThread", () => { expect(screen.getByText(/Child timed out after 30s/)).toBeInTheDocument() }) - it("renders sub-session turns with markdown when detail is available", () => { + it("does not surface the child's intermediate turns — only the broker's final outcome appears in the expanded body", () => { + // Persisted child turns (text + tool_use interleaved) used to leak + // into the parent's expanded body via SubThreadPreview. That replay + // pollutes context for the user — the MCP `delegate_to_agent` round + // trip only returns the final result, and the parent UI must match. mockedHook.mockReturnValue({ binding: bindingOf({ status: "ok" }), detail: { @@ -257,7 +333,7 @@ describe("DelegatedSubThread", () => { { id: "a1", role: "assistant", - blocks: [{ type: "text", text: "delegated answer body" }], + blocks: [{ type: "text", text: "intermediate reasoning" }], timestamp: "2026-05-23T00:00:05Z", }, ], @@ -265,12 +341,243 @@ describe("DelegatedSubThread", () => { loading: false, error: null, }) + const output = JSON.stringify({ + kind: "ok", + text: "Final result body.", + child_conversation_id: 99, + }) + renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("Final result body.")).toBeInTheDocument() + // Intermediate child turns must not leak into the parent's expanded + // body — neither the User/Assistant labels nor the intermediate text. + expect(screen.queryByText("User")).not.toBeInTheDocument() + expect(screen.queryByText("Assistant")).not.toBeInTheDocument() + expect(screen.queryByText("intermediate reasoning")).not.toBeInTheDocument() + }) + + it("uses meta.codeg.delegation to re-attach the child for live updates when the live binding is missing", () => { + // No live binding (page refresh mid-delegation), but the parent's + // tool-call snapshot carries meta — the component must dispatch a + // delegation-child attach so the child's streaming text can still + // reach the parent UI via the reducer. + mockedHook.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + const inputJson = JSON.stringify({ + agent_type: "codex", + task: "do a thing", + }) + renderWithIntl( + + ) + expect(mockAttachDelegationChild).toHaveBeenCalledWith({ + connectionId: "child-conn-meta", + parentConnectionId: "", + parentToolUseId: "pt-1", + agentType: "codex", + }) + }) + + it("real-time renders every text segment append-only + a 'sub-agent running' indicator below (never thinking, never tool_calls); subsequent segments never overwrite earlier ones", () => { + // The parent UI accumulates the child's assistant text segments + // in arrival order — each new "I'll do X" / "Now I'll do Y" stacks + // below the previous, never replaces it. A 'sub-agent running' + // indicator hangs off the bottom while status is "running". + // Hidden categories: + // - thinking blocks (internal reasoning) + // - tool_call blocks (intermediate steps) + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "running" }), + detail: null, + loading: false, + error: null, + }) + const baseChildConn = { + connectionId: "c1", + contextKey: "c1", + agentType: "codex", + workingDir: null, + status: "connected", + promptCapabilities: { + image: false, + audio: false, + embedded_context: false, + }, + supportsFork: false, + selectorsReady: true, + sessionId: null, + modes: null, + configOptions: null, + availableCommands: null, + usage: null, + pendingPermission: null, + pendingQuestion: null, + claudeApiRetry: null, + error: null, + loadError: null, + lastAppliedSeq: 0, + isDelegationChild: true, + parentToolUseId: "pt-1", + parentConnectionId: "p1", + } + + // Case 1: multiple text segments interleaved with thinking + tool_call. + // Every text segment appears (in order); non-text categories are + // filtered out; the running indicator is appended below. + mockChildConnection = { + ...baseChildConn, + liveMessage: { + id: "lm-1", + role: "assistant", + content: [ + { type: "thinking", text: "deliberating..." }, + { + type: "tool_call", + info: { title: "Run bash", kind: "execute", status: "completed" }, + }, + { type: "text", text: "preamble text" }, + { type: "text", text: "final tail text" }, + ], + startedAt: Date.now(), + }, + } + const { unmount } = renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + // Both text segments must be visible — later segments NEVER cover + // earlier ones. Segments are joined directly with no separator, so + // the rendered string contains both substrings in arrival order. + expect(screen.getByText(/preamble text/)).toBeInTheDocument() + expect(screen.getByText(/final tail text/)).toBeInTheDocument() + expect(screen.queryByText("deliberating...")).not.toBeInTheDocument() + expect(screen.queryByText(/Run bash/)).not.toBeInTheDocument() + // While status is "running" the indicator is appended below the + // text — it must coexist with the rendered text, never replace it. + expect(screen.getByText(/Sub-agent running/)).toBeInTheDocument() + unmount() + + // Case 2: tail block is a tool_call (child mid-tool, hasn't started + // the next text segment yet). The previous text persists AND the + // running indicator stays below it — neither flickers off, nor + // hides the other. + mockChildConnection = { + ...baseChildConn, + liveMessage: { + id: "lm-2", + role: "assistant", + content: [ + { type: "text", text: "earlier text" }, + { + type: "tool_call", + info: { title: "Run bash", kind: "execute", status: "in_progress" }, + }, + ], + startedAt: Date.now(), + }, + } + const { unmount: unmount2 } = renderWithIntl( + + ) + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("earlier text")).toBeInTheDocument() + expect(screen.getByText(/Sub-agent running/)).toBeInTheDocument() + expect(screen.queryByText(/Run bash/)).not.toBeInTheDocument() + unmount2() + + // Case 3: no text yet, only tool_calls — only the running indicator + // is visible (the previous "waitingForChild" wording is gone). + mockChildConnection = { + ...baseChildConn, + liveMessage: { + id: "lm-3", + role: "assistant", + content: [ + { + type: "tool_call", + info: { title: "Run bash", kind: "execute", status: "in_progress" }, + }, + ], + startedAt: Date.now(), + }, + } renderWithIntl() - // Collapsed card no longer surfaces the assistant's text in the header. - expect(screen.queryByText("delegated answer body")).not.toBeInTheDocument() fireEvent.click(screen.getByRole("button")) - expect(screen.getByText("Assistant")).toBeInTheDocument() - expect(screen.getByText("User")).toBeInTheDocument() - expect(screen.getByText("delegated answer body")).toBeInTheDocument() + expect(screen.getByText(/Sub-agent running/)).toBeInTheDocument() + expect( + screen.queryByText(/Waiting for the child agent to start/) + ).not.toBeInTheDocument() + expect(screen.queryByText(/Run bash/)).not.toBeInTheDocument() + }) + + it("surfaces the child's pending permission inline and auto-expands the card", () => { + mockedHook.mockReturnValue({ + binding: bindingOf({ status: "running" }), + detail: null, + loading: false, + error: null, + }) + mockChildConnection = { + connectionId: "c1", + contextKey: "c1", + agentType: "codex", + workingDir: null, + status: "connected", + promptCapabilities: { + image: false, + audio: false, + embedded_context: false, + }, + supportsFork: false, + selectorsReady: true, + sessionId: null, + modes: null, + configOptions: null, + availableCommands: null, + usage: null, + liveMessage: null, + pendingPermission: { + request_id: "req-9", + tool_call: { title: "Run bash", kind: "execute" }, + options: [ + { id: "approve", label: "Approve", kind: "allow_once" }, + { id: "deny", label: "Deny", kind: "reject_once" }, + ], + }, + pendingQuestion: null, + claudeApiRetry: null, + error: null, + loadError: null, + lastAppliedSeq: 0, + isDelegationChild: true, + parentToolUseId: "pt-1", + parentConnectionId: "p1", + } + renderWithIntl() + // No manual click — the card should have auto-expanded on first + // appearance of pendingPermission. The mocked PermissionDialog + // renders a sentinel that asserts its mount. + expect(screen.getByTestId("permission-dialog")).toBeInTheDocument() + expect(screen.getByText("permission for req-9")).toBeInTheDocument() }) }) diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 146ee6659..c4c9a000b 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -14,20 +14,33 @@ * lazily on first expand. */ -import { useMemo, useState } from "react" +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useSyncExternalStore, +} from "react" import { ChevronDown, ChevronRight, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" import { AgentIcon } from "@/components/agent-icon" import { MessageResponse } from "@/components/ai-elements/message" import { useDelegatedSubSession } from "@/hooks/use-delegated-sub-session" -import { - AGENT_LABELS, - type AgentType, - type ContentBlock, - type MessageTurn, -} from "@/lib/types" +import { AGENT_LABELS, type AgentType } from "@/lib/types" import type { ToolCallState } from "@/lib/adapters/ai-elements-adapter" +import { + type DelegationStatus, + useDelegation, +} from "@/contexts/delegation-context" +import { + useAcpActions, + useConnectionStore, + type ConnectionState, + type PendingPermission as ChildPendingPermission, +} from "@/contexts/acp-connections-context" +import { PermissionDialog } from "@/components/chat/permission-dialog" interface Props { parentToolUseId: string @@ -39,6 +52,15 @@ interface Props { output?: string | null errorText?: string | null state?: ToolCallState + /** + * ACP extensibility metadata on this tool call. Read here as a + * tertiary fallback after the live `DelegationContext` binding when + * the parent UI re-mounted on a page refresh and the live + * `delegation_started` event was already consumed (lost): the + * snapshot's `ToolCallState.meta["codeg.delegation"]` carries enough + * to re-bind the card to the child conversation. + */ + meta?: Record | null } type ParsedInput = { @@ -57,6 +79,86 @@ const KNOWN_AGENT_TYPES: ReadonlySet = new Set([ "open_claw", ]) +/** + * Subscribe to the child connection's `ConnectionState` (live message, + * pending permission, etc.) from the shared connections store. Returns + * `undefined` while no synthetic entry exists yet — caller falls back to + * the binding / persisted-turns view. Re-renders on every state change + * via `useSyncExternalStore`. + */ +function useDelegationChildLive( + childConnectionId: string | null +): ConnectionState | undefined { + const store = useConnectionStore() + const subscribe = useCallback( + (cb: () => void) => { + if (!childConnectionId) return () => {} + return store.subscribeKey(childConnectionId, cb) + }, + [store, childConnectionId] + ) + const getSnapshot = useCallback( + () => + childConnectionId ? store.getConnection(childConnectionId) : undefined, + [store, childConnectionId] + ) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} + +type ParsedMeta = { + status: DelegationStatus + childConnectionId: string | null + childConversationId: number | null + errorCode: string | null +} + +/** + * Extract delegation state from a `ToolCallState.meta` value. Returns + * `null` when the meta doesn't carry the `codeg.delegation` sub-object — + * caller falls back to the live binding / `parseInput` chain. + * + * The shape mirrors what the broker writes via `DelegationMetaWriter`: + * `{ "codeg.delegation": { status, child_connection_id?, + * child_conversation_id?, error_code? } }` + */ +function parseDelegationMeta( + meta: Record | null | undefined +): ParsedMeta | null { + if (!meta || typeof meta !== "object") return null + const inner = meta["codeg.delegation"] + if (!inner || typeof inner !== "object" || Array.isArray(inner)) return null + const obj = inner as Record + const rawStatus = obj["status"] + let status: DelegationStatus + switch (rawStatus) { + case "running": + case "pending": + status = "running" + break + case "completed": + case "ok": + status = "ok" + break + case "failed": + case "err": + status = "err" + break + default: + return null + } + const child_connection_id = obj["child_connection_id"] + const child_conversation_id = obj["child_conversation_id"] + const error_code = obj["error_code"] + return { + status, + childConnectionId: + typeof child_connection_id === "string" ? child_connection_id : null, + childConversationId: + typeof child_conversation_id === "number" ? child_conversation_id : null, + errorCode: typeof error_code === "string" ? error_code : null, + } +} + function parseInput(raw: string | null | undefined): ParsedInput { if (!raw || typeof raw !== "string") { return { @@ -138,27 +240,118 @@ export function DelegatedSubThread({ output, errorText, state, + meta, }: Props) { const t = useTranslations("Folder.chat.delegation") - const [expanded, setExpanded] = useState(false) - const { binding, detail, loading, error } = useDelegatedSubSession( - parentToolUseId, - { enabled: expanded } + // expanded is driven by user click OR by the arrival of a child + // pending permission. useReducer (not useState) so the in-effect + // auto-expand dispatch on first permission appearance doesn't trip + // the `react-hooks/set-state-in-effect` lint rule — same pattern as + // `use-delegated-sub-session.ts`. + const [expanded, dispatchExpand] = useReducer( + (prev: boolean, action: "toggle" | "force-open"): boolean => { + if (action === "force-open") return true + return !prev + }, + false ) - const parsed = useMemo(() => parseInput(input), [input]) + const parsedMeta = useMemo(() => parseDelegationMeta(meta), [meta]) + const { findByParentToolUseId } = useDelegation() + const { attachDelegationChild, respondPermission } = useAcpActions() + // `enabled: false` — we no longer surface the child conversation's + // intermediate turns in the parent UI (only the broker's final outcome + // text), so there's no reason to fetch the persisted detail. The hook + // is still useful for the `binding` it returns (agent type, status, + // child ids derived from the live `DelegationContext` map). + const { binding } = useDelegatedSubSession(parentToolUseId, { + enabled: false, + }) + + // Live view of the child connection's streaming state. Drives the + // expanded body's "streaming" branch — text/thinking/tool-call deltas + // reach this card the moment they arrive on the child's ACP stream, + // not just after the broker resolves. + const childConnectionId = + binding?.childConnectionId ?? parsedMeta?.childConnectionId ?? null + const childLive = useDelegationChildLive(childConnectionId) + const childPendingPermission = childLive?.pendingPermission ?? null - // Prefer binding-derived state (live event stream) when present, fall - // back to the parent ToolCall's own state/output so we still draw a - // sensible card even if the delegation events never arrived. + // Auto-expand the card the *first* time the child raises a permission + // request — the user has to act on it. Tracked via a ref so a user + // who deliberately collapses afterwards isn't forced back open on + // every reducer notify (the request_id stays the same across + // re-renders). + const lastSeenPermissionIdRef = useRef(null) + useEffect(() => { + const reqId = childPendingPermission?.request_id ?? null + if (reqId && reqId !== lastSeenPermissionIdRef.current) { + lastSeenPermissionIdRef.current = reqId + dispatchExpand("force-open") + } + if (!reqId) { + lastSeenPermissionIdRef.current = null + } + }, [childPendingPermission]) + + // Inline approve/deny — dispatch via the child connection's id, not + // the parent's. PermissionDialog already routes via the connectionId + // passed at construction time; for delegation the only consumer is + // this card, so wiring the child's id directly here is sufficient. + const onRespondPermission = useCallback( + (requestId: string, optionId: string) => { + if (!childConnectionId) return + void respondPermission(childConnectionId, requestId, optionId) + }, + [childConnectionId, respondPermission] + ) + + // Snapshot-recovery seed: when the parent's tool-call snapshot carries + // `meta["codeg.delegation"] = { status: "running", child_connection_id }` + // but the live `delegation_started` event has already been consumed + // (e.g. page refresh mid-delegation), pull the child connection into + // the reducer here so its streaming text reaches this card. Idempotent + // because `attachDelegationChild` early-returns when the synthetic + // entry already exists. + useEffect(() => { + const liveBinding = findByParentToolUseId(parentToolUseId) + if (!parsedMeta) return + if (parsedMeta.status !== "running") return + if (!parsedMeta.childConnectionId) return + if (liveBinding) return + if (!parsed.agentType) return + attachDelegationChild({ + connectionId: parsedMeta.childConnectionId, + // We don't know the parent's connection_id at this layer (the + // ToolCallPart doesn't carry it). Pass an empty string — the + // synthetic ConnectionState only uses parentConnectionId for + // diagnostic / cascade-cancel hooks; the routing of incoming + // events is by child connection_id alone. + parentConnectionId: "", + parentToolUseId, + agentType: parsed.agentType, + }) + }, [ + attachDelegationChild, + findByParentToolUseId, + parentToolUseId, + parsed.agentType, + parsedMeta, + ]) + + // Prefer binding-derived state (live event stream) when present, then + // the persisted `meta["codeg.delegation"]` from the snapshot (page + // refresh recovery), then the parent ToolCall's own state/output as a + // last resort. const agentType: AgentType | null = binding?.agentType ?? parsed.agentType const status: "running" | "ok" | "err" = (() => { if (binding) return binding.status + if (parsedMeta) return parsedMeta.status if (state === "output-error" || errorText) return "err" if (state === "output-available") return "ok" return "running" })() - const errorCode = binding?.errorCode + const errorCode = binding?.errorCode ?? parsedMeta?.errorCode ?? undefined // Parse the broker's structured outcome out of the raw tool output so // the expanded body can render markdown text instead of `{"kind":"ok", @@ -171,6 +364,26 @@ export function DelegatedSubThread({ return parseDelegationOutcome(output) }, [output, errorText]) + // Real-time view of the child's assistant text — *all* text segments + // concatenated in arrival order, with no separator. We deliberately + // strip: + // - thinking blocks (internal reasoning, not the result) + // - tool_call / plan blocks (intermediate steps) + // but we keep every text segment so the user sees the child's visible + // output grow append-only. Each new segment is appended directly to + // whatever has accumulated so far; later segments NEVER overwrite + // earlier ones. Once the broker's outcome lands on `output`, + // `outcome.text` takes over. + const liveStreamText = useMemo(() => { + const blocks = childLive?.liveMessage?.content ?? [] + const parts: string[] = [] + for (const b of blocks) { + if (b.type === "text" && b.text.trim().length > 0) parts.push(b.text) + } + if (parts.length === 0) return null + return parts.join("") + }, [childLive]) + // Caller (ToolCallPart) already guarantees this is a `delegate_to_agent` // tool, but a snapshot replay with an empty/unparseable input AND no live // binding has no useful card to draw — fall through to the standard @@ -187,7 +400,7 @@ export function DelegatedSubThread({ > - + +
+ ))} + {resourceAttachments.map((attachment) => ( +
- - -
- ))} - {resourceAttachments.map((attachment) => ( -
- - {attachment.name} - +
+ ))} + + } + /> +