diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d5408eaa9f..d7cdd0ab0f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,9 +12,9 @@ Link the GitHub issue this PR addresses. Before opening this PR, please confirm: - [ ] I have manually tested my changes locally with `./script/run` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54f65fc787..b4dabfce44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,7 +141,7 @@ cargo run # build and run Warp Tests are required for most code changes: ### Manual Testing -Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end in the PR description. +Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end in the PR description. You can run the app locally using `./script/run` - see [WARP.md](WARP.md) for more details on how to get set up. diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 5e6ca53536..6d6e218863 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -81,6 +81,7 @@ pub fn convert_conversation_data_to_ai_conversation( artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -97,6 +98,7 @@ pub fn convert_conversation_data_to_ai_conversation( artifacts_json: serde_json::to_string(&metadata.artifacts).ok(), parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, // TODO: Populate run_id from server metadata once it is exposed diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 8cbf979e8c..8ca966035e 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -23,6 +23,7 @@ use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::{collections::HashMap, fmt::Display}; +use warp_cli::agent::Harness; use super::task_store::TaskStore; use uuid::Uuid; @@ -217,6 +218,8 @@ pub struct AIConversation { parent_agent_id: Option, /// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator. agent_name: Option, + /// Harness metadata associated with this child agent in orchestration flows. + orchestration_harness_type: Option, /// The local conversation ID of the parent that spawned this child, if any. parent_conversation_id: Option, /// True when this conversation is a placeholder for a child agent executing @@ -282,6 +285,7 @@ impl AIConversation { artifacts: Vec::new(), parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, last_event_sequence: None, @@ -364,6 +368,7 @@ impl AIConversation { artifacts, parent_agent_id, agent_name, + orchestration_harness_type, parent_conversation_id, is_remote_child, run_id, @@ -388,6 +393,7 @@ impl AIConversation { .unwrap_or_default(); let parent_agent_id = data.parent_agent_id; let agent_name = data.agent_name; + let orchestration_harness_type = data.orchestration_harness_type; let parent_conversation_id = data .parent_conversation_id .and_then(|id| AIConversationId::try_from(id).ok()); @@ -410,6 +416,7 @@ impl AIConversation { artifacts, parent_agent_id, agent_name, + orchestration_harness_type, parent_conversation_id, is_remote_child, run_id, @@ -426,6 +433,7 @@ impl AIConversation { None, None, None, + None, false, None, AIConversationAutoexecuteMode::default(), @@ -470,6 +478,7 @@ impl AIConversation { artifacts, parent_agent_id, agent_name, + orchestration_harness_type, parent_conversation_id, is_remote_child, last_event_sequence, @@ -808,6 +817,24 @@ impl AIConversation { pub fn set_agent_name(&mut self, name: String) { self.agent_name = Some(name); } + pub fn orchestration_harness_type(&self) -> Option<&str> { + self.orchestration_harness_type.as_deref() + } + + pub fn orchestration_harness(&self) -> Option { + self.orchestration_harness_type + .as_deref() + .map(parse_orchestration_harness_type) + .or_else(|| { + self.server_metadata + .as_ref() + .map(|metadata| Harness::from(metadata.harness)) + }) + } + + pub fn set_orchestration_harness(&mut self, harness: Harness) { + self.orchestration_harness_type = Some(harness.config_name().to_string()); + } pub fn parent_conversation_id(&self) -> Option { self.parent_conversation_id @@ -2909,6 +2936,7 @@ impl AIConversation { artifacts_json, parent_agent_id: self.parent_agent_id.clone(), agent_name: self.agent_name.clone(), + orchestration_harness_type: self.orchestration_harness_type.clone(), parent_conversation_id: self.parent_conversation_id.map(|id| id.to_string()), is_remote_child: self.is_remote_child, run_id: self.task_id.map(|id| id.to_string()), @@ -3407,6 +3435,11 @@ impl AIConversation { } } +fn parse_orchestration_harness_type(value: &str) -> Harness { + Harness::from_config_name(value) + .or_else(|| Harness::parse_orchestration_harness(value)) + .unwrap_or(Harness::Unknown) +} pub(super) fn update_todo_list_from_todo_op( todo_lists: &mut Vec, op: api::message::update_todos::Operation, diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index d42ca6110d..cc5377c046 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -255,6 +255,7 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -309,6 +310,7 @@ fn test_display_status_uses_active_execution_over_previous_conversation_status() artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -370,6 +372,7 @@ fn test_display_status_updates_when_blocked_conversation_resumes() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -447,6 +450,7 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -500,6 +504,7 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -789,6 +794,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_run_id() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -841,6 +847,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_server_token() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1008,6 +1015,7 @@ fn test_resolve_open_action_falls_back_to_local_conversation_for_invalid_session artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -1297,6 +1305,7 @@ fn test_server_token_assignment_updates_copy_link_resolution() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1391,6 +1400,7 @@ fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_tok artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -1715,6 +1725,7 @@ fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.clone()), @@ -1773,6 +1784,7 @@ fn test_get_entries_prefers_task_when_server_token_matches() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs index 1ab14b44c7..efd44a7118 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs @@ -66,6 +66,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() { terminal_view_id, "Agent 1".to_string(), parent_conversation_id, + None, ctx, ) }); @@ -154,6 +155,7 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization terminal_view_id, "Agent 1".to_string(), parent_conversation_id, + None, ctx, ) }); @@ -410,6 +412,7 @@ fn parallel_pendings_each_resolve_independently_via_recorded_child_id() { terminal_view_id, "Agent A".to_string(), parent_conversation_id, + None, ctx, ) }); @@ -418,6 +421,7 @@ fn parallel_pendings_each_resolve_independently_via_recorded_child_id() { terminal_view_id, "Agent B".to_string(), parent_conversation_id, + None, ctx, ) }); diff --git a/app/src/ai/blocklist/agent_view/mod.rs b/app/src/ai/blocklist/agent_view/mod.rs index b8892d9264..0dc9094097 100644 --- a/app/src/ai/blocklist/agent_view/mod.rs +++ b/app/src/ai/blocklist/agent_view/mod.rs @@ -6,6 +6,7 @@ mod controller; mod ephemeral_message_model; mod inline_agent_view_header; // TODO: Move orchestration_conversation_links module import elsewhere. +pub(crate) mod orchestration_avatar; pub(crate) mod orchestration_conversation_links; pub mod orchestration_pill_bar; pub mod shortcuts; diff --git a/app/src/ai/blocklist/agent_view/orchestration_avatar.rs b/app/src/ai/blocklist/agent_view/orchestration_avatar.rs new file mode 100644 index 0000000000..bbe8ae846f --- /dev/null +++ b/app/src/ai/blocklist/agent_view/orchestration_avatar.rs @@ -0,0 +1,41 @@ +use warpui::elements::Element; +use warpui::{AppContext, SingletonEntity}; + +use crate::ai::blocklist::agent_view::orchestration_pill_bar::{ + render_agent_avatar_disc, render_orchestrator_avatar_disc, +}; +use crate::appearance::Appearance; + +const TRANSCRIPT_AVATAR_SCALE: f32 = 1.25; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum OrchestrationAvatar { + Orchestrator, + Agent { display_name: String }, +} + +impl OrchestrationAvatar { + pub(crate) fn agent(display_name: String) -> Self { + Self::Agent { display_name } + } + + pub(crate) fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let size = app.font_cache().line_height( + appearance.monospace_font_size(), + appearance.line_height_ratio(), + ) * TRANSCRIPT_AVATAR_SCALE; + + match self { + Self::Orchestrator => render_orchestrator_avatar_disc(size, theme, appearance), + Self::Agent { display_name } => { + render_agent_avatar_disc(display_name, size, theme, appearance) + } + } + } +} + +#[cfg(test)] +#[path = "orchestration_avatar_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/agent_view/orchestration_avatar_tests.rs b/app/src/ai/blocklist/agent_view/orchestration_avatar_tests.rs new file mode 100644 index 0000000000..3b1ccc987f --- /dev/null +++ b/app/src/ai/blocklist/agent_view/orchestration_avatar_tests.rs @@ -0,0 +1,11 @@ +use super::OrchestrationAvatar; + +#[test] +fn agent_avatar_identity_is_display_name_based_for_pill_consistency() { + assert_eq!( + OrchestrationAvatar::agent("Agent 1".to_string()), + OrchestrationAvatar::Agent { + display_name: "Agent 1".to_string(), + } + ); +} diff --git a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs index 1ebdbfaa12..3212db2fec 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs @@ -1,5 +1,5 @@ //! Horizontal pill bar shown above the agent view header listing the -//! orchestrator agent and its child agents. Clicking a pill switches the +//! orchestrator and its child agents. Clicking a pill switches the //! active pane to that agent's conversation. use std::cell::RefCell; @@ -79,6 +79,38 @@ fn pill_initial(name: &str) -> char { .map(|c| c.to_ascii_uppercase()) .unwrap_or('A') } +/// Renders the orchestrator avatar disc shared by pill, breadcrumb, and transcript +/// surfaces. +pub(super) fn render_orchestrator_avatar_disc( + size: f32, + theme: &WarpTheme, + appearance: &Appearance, +) -> Box { + render_avatar_disc( + theme.ansi_fg_cyan(), + AvatarGlyph::Icon(Icon::Oz), + size, + theme, + appearance, + ) +} + +/// Renders a child-agent avatar using the same deterministic-color + initial-letter +/// treatment as the orchestration pill bar. +pub(super) fn render_agent_avatar_disc( + name: &str, + size: f32, + theme: &WarpTheme, + appearance: &Appearance, +) -> Box { + render_avatar_disc( + pill_avatar_color(name, theme), + AvatarGlyph::Letter(pill_initial(name)), + size, + theme, + appearance, + ) +} /// What kind of pill we are rendering, which determines click behavior. #[derive(Clone, Copy)] @@ -442,9 +474,7 @@ impl OrchestrationPillBar { pub fn render_static_agent_pill(name: &str, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); - let avatar_color = pill_avatar_color(name, theme); - let avatar_glyph = AvatarGlyph::Letter(pill_initial(name)); - let avatar = render_avatar_disc(avatar_color, avatar_glyph, theme, appearance); + let avatar = render_agent_avatar_disc(name, AVATAR_SIZE, theme, appearance); let label_text = Text::new(name.to_string(), appearance.ui_font_family(), 12.) .with_color(internal_colors::text_main(theme, theme.background())) .soft_wrap(false) @@ -859,9 +889,12 @@ fn render_hover_card( // (mapped to icon+color via `status_icon_and_color`) to drive the // badge so the card matches the colors used elsewhere in the agent // details panel. - let avatar_color = pill_avatar_color(&name, theme); - let avatar_glyph = AvatarGlyph::Letter(pill_initial(&name)); - let avatar = render_avatar_disc(avatar_color, avatar_glyph, theme, appearance); + let is_orchestrator = conversation.parent_conversation_id().is_none(); + let avatar = if is_orchestrator { + render_orchestrator_avatar_disc(AVATAR_SIZE, theme, appearance) + } else { + render_agent_avatar_disc(&name, AVATAR_SIZE, theme, appearance) + }; let name_text = Text::new( name, appearance.ui_font_family(), @@ -882,7 +915,6 @@ fn render_hover_card( // the orchestration as a whole. Until we plumb an aggregated // child-status accessor we hide the badge for the orchestrator pill // — child pills still show the (per-child accurate) badge. - let is_orchestrator = conversation.parent_conversation_id().is_none(); // Cap the badge at a fixed width so it can't shove the name out of // the card. Slightly larger than the longest expected status label // ("In progress") plus its icon and padding. @@ -1267,7 +1299,7 @@ fn render_pill( .with_height(AVATAR_SIZE) .finish() } else { - render_avatar_disc(avatar_color, avatar_glyph, theme, appearance) + render_avatar_disc(avatar_color, avatar_glyph, AVATAR_SIZE, theme, appearance) }; // Body row contains just the avatar + label — the 3-dot button @@ -1470,22 +1502,24 @@ fn render_overflow_button( fn render_avatar_disc( avatar_color: ColorU, glyph: AvatarGlyph, + size: f32, theme: &WarpTheme, appearance: &Appearance, ) -> Box { let disc = ConstrainedBox::new( Container::new(Empty::new().finish()) .with_background_color(avatar_color) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(AVATAR_SIZE / 2.))) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(size / 2.))) .finish(), ) - .with_width(AVATAR_SIZE) - .with_height(AVATAR_SIZE) + .with_width(size) + .with_height(size) .finish(); + let glyph_size = size * 0.625; let glyph_element: Box = match glyph { AvatarGlyph::Letter(letter) => { - Text::new(letter.to_string(), appearance.ui_font_family(), 10.) + Text::new(letter.to_string(), appearance.ui_font_family(), glyph_size) .with_color(theme.background().into_solid()) .with_style(Properties { weight: Weight::Bold, @@ -1495,8 +1529,8 @@ fn render_avatar_disc( } AvatarGlyph::Icon(icon) => { ConstrainedBox::new(icon.to_warpui_icon(theme.background()).finish()) - .with_width(10.) - .with_height(10.) + .with_width(glyph_size) + .with_height(glyph_size) .finish() } }; @@ -1519,8 +1553,8 @@ fn render_avatar_disc( ) .finish(), ) - .with_width(AVATAR_SIZE) - .with_height(AVATAR_SIZE) + .with_width(size) + .with_height(size) .finish(); Stack::new() @@ -1923,7 +1957,7 @@ fn build_crumb_inner( .with_clip(ClipConfig::ellipsis()) .finish(); - let avatar = render_avatar_disc(avatar_color, avatar_glyph, theme, appearance); + let avatar = render_avatar_disc(avatar_color, avatar_glyph, AVATAR_SIZE, theme, appearance); let row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index 7a5a37a9e4..5675b0f722 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -781,6 +781,14 @@ impl CollapsibleElementState { } } +const RECEIVED_MESSAGE_COLLAPSIBLE_ID_PREFIX: &str = "received-message:"; + +pub(crate) fn received_message_collapsible_id(message_id: &str) -> MessageId { + MessageId::new(format!( + "{RECEIVED_MESSAGE_COLLAPSIBLE_ID_PREFIX}{message_id}" + )) +} + pub struct AIBlock { model: Rc>, terminal_model: Arc>, @@ -2054,19 +2062,29 @@ impl AIBlock { } // Register collapsible state for orchestration action messages. - if FeatureFlag::Orchestration.is_enabled() - && matches!( - &message.message, + if FeatureFlag::Orchestration.is_enabled() { + match &message.message { AIAgentOutputMessageType::Action(AIAgentAction { - action: AIAgentActionType::StartAgent { .. } + action: + AIAgentActionType::StartAgent { .. } | AIAgentActionType::SendMessageToAgent { .. }, .. - }) | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } - ) - { - self.collapsible_block_states - .entry(message.id.clone()) - .or_default(); + }) => { + self.collapsible_block_states + .entry(message.id.clone()) + .or_default(); + } + AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { + for received_message in messages { + self.collapsible_block_states + .entry(received_message_collapsible_id( + &received_message.message_id, + )) + .or_default(); + } + } + _ => {} + } } } diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index 71c757bc8e..1e1f0836ac 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -3,7 +3,7 @@ use pathfinder_color::ColorU; use warpui::elements::{ ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable, - MouseStateHandle, ParentElement, Radius, Text, + MouseStateHandle, ParentElement, Radius, Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::{AppContext, Element, SingletonEntity}; @@ -17,11 +17,14 @@ use crate::ai::agent::{ SendMessageToAgentResult, StartAgentExecutionMode, StartAgentResult, }; use crate::ai::blocklist::action_model::AIActionStatus; +use crate::ai::blocklist::agent_view::orchestration_avatar::OrchestrationAvatar; use crate::ai::blocklist::agent_view::orchestration_conversation_links::{ conversation_id_for_agent_id, conversation_navigation_card_with_icon, }; use crate::ai::blocklist::block::model::AIBlockModelHelper; -use crate::ai::blocklist::block::{AIBlockAction, CollapsibleExpansionState}; +use crate::ai::blocklist::block::{ + received_message_collapsible_id, AIBlockAction, CollapsibleExpansionState, +}; use crate::ai::blocklist::inline_action::inline_action_header::{ ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING, }; @@ -41,25 +44,84 @@ use super::WithContentItemSpacing; const GENERATING_TITLE_PLACEHOLDER: &str = "Generating title..."; const ORCHESTRATION_COLLAPSED_MAX_HEIGHT: f32 = 200.; +#[derive(Clone, Debug, PartialEq, Eq)] +struct OrchestrationParticipant { + display_name: String, + avatar: OrchestrationAvatar, +} + +impl OrchestrationParticipant { + fn orchestrator() -> Self { + Self { + display_name: "Orchestrator".to_string(), + avatar: OrchestrationAvatar::Orchestrator, + } + } + + fn unknown_child() -> Self { + Self { + display_name: "Unknown agent".to_string(), + avatar: OrchestrationAvatar::agent("Unknown agent".to_string()), + } + } + fn is_orchestrator(&self) -> bool { + matches!(&self.avatar, OrchestrationAvatar::Orchestrator) + } +} + +#[cfg(test)] fn agent_display_name_from_id( agent_id: &str, orchestrator_agent_id: Option<&str>, app: &AppContext, ) -> String { - if orchestrator_agent_id.is_some_and(|id| id == agent_id) { - return "Orchestrator agent".to_string(); - } + participant_for_agent_id(agent_id, orchestrator_agent_id, app).display_name +} + +fn participant_for_agent_id( + agent_id: &str, + orchestrator_agent_id: Option<&str>, + app: &AppContext, +) -> OrchestrationParticipant { if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) { - if let Some(agent_name) = conversation.agent_name() { - return agent_name.to_string(); - } + return participant_for_conversation( + conversation, + orchestrator_agent_id, + Some(agent_id), + ); } } - "Unknown agent".to_string() + if orchestrator_agent_id.is_some_and(|id| id == agent_id) { + return OrchestrationParticipant::orchestrator(); + } + OrchestrationParticipant::unknown_child() +} + +fn participant_for_conversation( + conversation: &AIConversation, + orchestrator_agent_id: Option<&str>, + agent_id: Option<&str>, +) -> OrchestrationParticipant { + let is_orchestrator = agent_id + .map(|id| { + orchestrator_agent_id.is_some_and(|orchestrator_id| id == orchestrator_id) + || (orchestrator_agent_id.is_none() + && conversation.parent_conversation_id().is_none()) + }) + .unwrap_or_else(|| conversation.parent_conversation_id().is_none()); + if is_orchestrator { + return OrchestrationParticipant::orchestrator(); + } + + let display_name = conversation.agent_name().unwrap_or("Agent").to_string(); + OrchestrationParticipant { + display_name: display_name.clone(), + avatar: OrchestrationAvatar::agent(display_name), + } } fn orchestrator_agent_id_for_conversation( @@ -74,118 +136,185 @@ fn orchestrator_agent_id_for_conversation( } } -fn render_message_fields( - fields: &[(&str, &str)], - body: &str, +fn participant_for_current_conversation( + props: Props, + orchestrator_agent_id: Option<&str>, + app: &AppContext, +) -> OrchestrationParticipant { + props + .model + .conversation(app) + .map(|conversation| { + participant_for_conversation( + conversation, + orchestrator_agent_id, + conversation.orchestration_agent_id().as_deref(), + ) + }) + .unwrap_or_else(OrchestrationParticipant::orchestrator) +} + +fn transcript_metadata(recipients: &[OrchestrationParticipant], subject: &str) -> Option { + let recipients = recipients + .iter() + .filter(|participant| !participant.is_orchestrator()) + .map(|participant| participant.display_name.as_str()) + .collect::>() + .join(", "); + match (recipients.is_empty(), subject.is_empty()) { + (true, true) => None, + (true, false) => Some(subject.to_string()), + (false, true) => Some(format!("to {recipients}")), + (false, false) => Some(format!("to {recipients} • {subject}")), + } +} + +struct TranscriptRowData<'a> { + participant: &'a OrchestrationParticipant, + recipients: &'a [OrchestrationParticipant], + subject: &'a str, + body: &'a str, + message_id: &'a MessageId, + is_streaming: bool, +} + +fn render_transcript_row( + data: TranscriptRowData<'_>, + props: Props, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let font_family = appearance.ui_font_family(); let font_size = appearance.monospace_font_size(); - let label_color = blended_colors::text_disabled(theme, theme.surface_2()); - let value_color: ColorU = theme.main_text_color(theme.background()).into(); - - let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + let metadata_color = blended_colors::text_disabled(theme, theme.surface_2()); + let body_color: ColorU = theme.main_text_color(theme.background()).into(); + let chevron = if data.body.is_empty() { + None + } else { + render_collapse_chevron(data.message_id, props, app) + }; - for (label, value) in fields { - let line = Flex::row() - .with_child( - Text::new(label.to_string(), font_family, font_size) - .with_color(label_color) - .finish(), - ) - .with_child( - Text::new(value.to_string(), font_family, font_size) - .with_color(value_color) - .finish(), - ) - .finish(); - column.add_child(line); + let name = FormattedTextFragment::bold(&data.participant.display_name); + let header = render_formatted_text_element(vec![name], app).finish(); + let mut header_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); + header_row.add_child(Shrinkable::new(1., header).finish()); + if let Some(chevron) = chevron { + header_row.add_child(Container::new(chevron).with_margin_left(6.).finish()); } - if !body.is_empty() { - column.add_child( + let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + content.add_child(header_row.finish()); + if let Some(metadata) = transcript_metadata(data.recipients, data.subject) { + content.add_child( Container::new( - Text::new(body.to_string(), font_family, font_size) - .with_color(value_color) + Text::new(metadata, font_family, font_size) + .with_color(metadata_color) + .with_selectable(true) .finish(), ) - .with_margin_top(4.) + .with_margin_top(2.) .finish(), ); } + if !data.body.is_empty() { + let body_element = Container::new( + Text::new(data.body.to_string(), font_family, font_size) + .with_color(body_color) + .with_selectable(true) + .finish(), + ) + .with_margin_top(8.) + .finish(); + if let Some(body) = + render_collapsible_body(data.message_id, body_element, data.is_streaming, props) + { + content.add_child(body); + } + } - column.finish() + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Container::new(data.participant.avatar.render(app)) + .with_margin_right(12.) + .finish(), + ) + .with_child(Shrinkable::new(1., content.finish()).finish()) + .finish() } pub(super) fn render_messages_received_from_agents( messages: &[ReceivedMessageDisplay], props: Props, - message_id: &MessageId, app: &AppContext, ) -> Box { - let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - - let status_icon = inline_action_icons::green_check_icon(appearance).finish(); - let chevron = render_collapse_chevron(message_id, props, app); - - let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - - // Header row with icon and collapse chevron - let header = render_requested_action_row_for_text( - format!("Messages received ({})", messages.len()).into(), - appearance.ui_font_family(), - Some(status_icon), - chevron, - false, - false, - app, - ); - column.add_child(header); - + if messages.is_empty() { + return Empty::new().finish(); + } let orchestrator_agent_id = props .model .conversation(app) .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); - - // Collect all messages into a single collapsible body. - let mut messages_column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - for msg in messages { - let sender_name = - agent_display_name_from_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app); + let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + for (index, msg) in messages.iter().enumerate() { + let sender = + participant_for_agent_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app); let recipients = msg .addresses .iter() .map(|agent_id| { - agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app) + participant_for_agent_id(agent_id, orchestrator_agent_id.as_deref(), app) }) - .collect::>() - .join(", "); - let fields = [ - ("From: ", sender_name.as_str()), - ("To: ", recipients.as_str()), - ("Subject: ", msg.subject.as_str()), - ]; - let message_block = Container::new(render_message_fields(&fields, &msg.message_body, app)) - .with_margin_top(8.) - .with_margin_left(8.) - .finish(); - messages_column.add_child(message_block); + .collect::>(); + let row_message_id = received_message_collapsible_id(&msg.message_id); + let row = render_transcript_row( + TranscriptRowData { + participant: &sender, + recipients: &recipients, + subject: &msg.subject, + body: &msg.message_body, + message_id: &row_message_id, + is_streaming: false, + }, + props, + app, + ); + let mut row_container = Container::new(row); + if index > 0 { + row_container = row_container.with_margin_top(12.); + } + column.add_child(row_container.finish()); } - if let Some(body) = render_collapsible_body(message_id, messages_column.finish(), false, props) - { - column.add_child(body); - } + column.finish().with_agent_output_item_spacing(app).finish() +} - Container::new(column.finish()) - .with_horizontal_padding(8.) - .with_vertical_padding(8.) - .with_background_color(blended_colors::neutral_2(theme)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) - .finish() +fn participant_display_names(participants: &[OrchestrationParticipant]) -> String { + participants + .iter() + .map(|participant| participant.display_name.as_str()) + .collect::>() + .join(", ") +} + +fn participant_for_agent_ids( + agent_ids: &[String], + orchestrator_agent_id: Option<&str>, + app: &AppContext, +) -> Vec { + agent_ids + .iter() + .map(|agent_id| participant_for_agent_id(agent_id, orchestrator_agent_id, app)) + .collect() +} + +fn render_transcript_row_with_spacing( + data: TranscriptRowData<'_>, + props: Props, + app: &AppContext, +) -> Box { + render_transcript_row(data, props, app) .with_agent_output_item_spacing(app) .finish() } @@ -206,11 +335,9 @@ pub(super) fn render_send_message( .model .conversation(app) .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); - let recipients = address - .iter() - .map(|agent_id| agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app)) - .collect::>() - .join(", "); + let recipient_participants = + participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app); + let recipients = participant_display_names(&recipient_participants); if let Some(AIActionStatus::Finished(result)) = &status { let AIAgentActionResultType::SendMessageToAgent(result) = &result.result else { @@ -222,40 +349,23 @@ pub(super) fn render_send_message( }; match result { SendMessageToAgentResult::Success { .. } => { - let status_icon = inline_action_icons::green_check_icon(appearance).finish(); - let chevron = render_collapse_chevron(message_id, props, app); - let header = render_requested_action_row_for_text( - format!("Sent message to {recipients}: {subject}").into(), - appearance.ui_font_family(), - Some(status_icon), - chevron, - false, - false, + let sender = participant_for_current_conversation( + props, + orchestrator_agent_id.as_deref(), + app, + ); + return render_transcript_row_with_spacing( + TranscriptRowData { + participant: &sender, + recipients: &recipient_participants, + subject, + body: message, + message_id, + is_streaming: false, + }, + props, app, ); - - let fields = [("To: ", recipients.as_str()), ("Subject: ", subject)]; - let body_element = Container::new(render_message_fields(&fields, message, app)) - .with_margin_top(4.) - .with_margin_left(8.) - .finish(); - - let mut column = - Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - column.add_child(header); - if let Some(body) = render_collapsible_body(message_id, body_element, false, props) - { - column.add_child(body); - } - - return Container::new(column.finish()) - .with_horizontal_padding(8.) - .with_vertical_padding(8.) - .with_background_color(blended_colors::neutral_2(theme)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) - .finish() - .with_agent_output_item_spacing(app) - .finish(); } SendMessageToAgentResult::Error(error) => { let label = format!("Failed to send message to {recipients}: {error}"); diff --git a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs index 3633b15a1f..0d6f11d6b1 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs @@ -2,15 +2,16 @@ use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult}; use crate::BlocklistAIHistoryModel; use ai::agent::action_result::StartAgentVersion; +use warp_cli::agent::Harness; use warp_core::ui::appearance::Appearance; use warpui::elements::MouseStateHandle; use warpui::{App, EntityId}; use super::{ - agent_display_name_from_id, child_conversation_card_data_for_result, + agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id, render_conversation_navigation_card_row, start_agent_cancelled_prefix, start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix, - ChildConversationCardData, + transcript_metadata, ChildConversationCardData, OrchestrationAvatar, OrchestrationParticipant, }; #[test] @@ -199,7 +200,7 @@ fn agent_display_name_from_id_returns_orchestrator_label() { let actual = app.read(|ctx| { agent_display_name_from_id("orchestrator-agent-id", Some("orchestrator-agent-id"), ctx) }); - assert_eq!(actual, "Orchestrator agent"); + assert_eq!(actual, "Orchestrator"); }); } @@ -212,6 +213,86 @@ fn agent_display_name_from_id_returns_unknown_fallback() { assert_eq!(actual, "Unknown agent"); }); } +#[test] +fn participant_for_agent_id_uses_pill_style_child_agent_avatar() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + history_model.update(&mut app, |history_model, ctx| { + let terminal_view_id = EntityId::new(); + let parent_conversation_id = + history_model.start_new_conversation(terminal_view_id, false, false, ctx); + history_model.set_server_conversation_token_for_conversation( + parent_conversation_id, + "orchestrator-agent-id".to_string(), + ); + let child_conversation_id = history_model.start_new_child_conversation( + terminal_view_id, + "Agent 1".to_string(), + parent_conversation_id, + Some(Harness::Claude), + ctx, + ); + history_model.set_server_conversation_token_for_conversation( + child_conversation_id, + "child-agent-id".to_string(), + ); + }); + + let actual = app.read(|ctx| { + participant_for_agent_id("child-agent-id", Some("orchestrator-agent-id"), ctx) + }); + assert_eq!(actual.display_name, "Agent 1"); + assert_eq!( + actual.avatar, + OrchestrationAvatar::agent("Agent 1".to_string()) + ); + }); +} + +#[test] +fn transcript_metadata_uses_transcript_copy_without_technical_labels() { + let recipients = vec![OrchestrationParticipant { + display_name: "Agent 1".to_string(), + avatar: OrchestrationAvatar::agent("Agent 1".to_string()), + }]; + + let metadata = transcript_metadata(&recipients, "Fix tests").expect("metadata"); + + assert_eq!(metadata, "to Agent 1 • Fix tests"); + for legacy_label in ["Messages received", "From:", "To:", "Subject:"] { + assert!( + !metadata.contains(legacy_label), + "Transcript metadata should not contain old technical label {legacy_label}: {metadata}" + ); + } +} + +#[test] +fn transcript_metadata_omits_orchestrator_recipients() { + let recipients = vec![OrchestrationParticipant::orchestrator()]; + + assert_eq!( + transcript_metadata(&recipients, "Status update"), + Some("Status update".to_string()) + ); + assert_eq!(transcript_metadata(&recipients, ""), None); +} + +#[test] +fn transcript_metadata_preserves_non_orchestrator_recipients() { + let recipients = vec![ + OrchestrationParticipant::orchestrator(), + OrchestrationParticipant { + display_name: "Agent 1".to_string(), + avatar: OrchestrationAvatar::agent("Agent 1".to_string()), + }, + ]; + + assert_eq!( + transcript_metadata(&recipients, "Fix tests"), + Some("to Agent 1 • Fix tests".to_string()) + ); +} #[test] fn conversation_navigation_card_row_renders_title_without_legacy_subtitle() { diff --git a/app/src/ai/blocklist/block/view_impl/output.rs b/app/src/ai/blocklist/block/view_impl/output.rs index 52ab3d4a12..cc4593ff4f 100644 --- a/app/src/ai/blocklist/block/view_impl/output.rs +++ b/app/src/ai/blocklist/block/view_impl/output.rs @@ -902,10 +902,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box { { output_items.add_child( orchestration::render_messages_received_from_agents( - messages, - props, - &output_message.id, - app, + messages, props, app, ), ); } diff --git a/app/src/ai/blocklist/block_tests.rs b/app/src/ai/blocklist/block_tests.rs index 6f40b0b541..e8143f375f 100644 --- a/app/src/ai/blocklist/block_tests.rs +++ b/app/src/ai/blocklist/block_tests.rs @@ -1,4 +1,4 @@ -use super::{CollapsibleElementState, CollapsibleExpansionState}; +use super::{received_message_collapsible_id, CollapsibleElementState, CollapsibleExpansionState}; use crate::ai::agent::StartAgentExecutionMode; use crate::ai::blocklist::action_model::{ compose_run_agents_child_prompt, run_agents_to_start_agent_mode, @@ -93,6 +93,16 @@ fn manual_reexpand_while_streaming_stays_expanded_after_finish() { }); } +#[test] +fn received_message_collapsible_id_prefixes_row_ids() { + let first = received_message_collapsible_id("message-1"); + let second = received_message_collapsible_id("message-2"); + + assert_eq!(&*first, "received-message:message-1"); + assert_eq!(&*second, "received-message:message-2"); + assert_ne!(first, second); +} + #[test] fn compose_child_prompt_concatenates_when_both_non_empty() { let composed = compose_run_agents_child_prompt("base", "do X"); diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 082bacb518..2e475ce91c 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Local, NaiveDateTime}; use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use warp_cli::agent::Harness; use warp_core::features::FeatureFlag; use warp_multi_agent_api::response_event::stream_finished::ConversationUsageMetadata; use warp_multi_agent_api::{ @@ -397,6 +398,7 @@ impl BlocklistAIHistoryModel { terminal_view_id: EntityId, name: String, parent_conversation_id: AIConversationId, + orchestration_harness: Option, ctx: &mut ModelContext, ) -> AIConversationId { let parent_agent_id = self @@ -420,6 +422,9 @@ impl BlocklistAIHistoryModel { conversation.set_parent_agent_id(id); } conversation.set_agent_name(name); + if let Some(harness) = orchestration_harness { + conversation.set_orchestration_harness(harness); + } } self.set_parent_for_conversation(conversation_id, parent_conversation_id); conversation_id @@ -1152,6 +1157,7 @@ impl BlocklistAIHistoryModel { // Forked conversation loses its parentage parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1307,6 +1313,7 @@ impl BlocklistAIHistoryModel { // Forked conversation loses its parentage. parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 4a7eb99928..7868f87572 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -3,6 +3,7 @@ use std::time::Duration; use chrono::{DateTime, Local, Utc}; use itertools::Itertools; +use warp_cli::agent::Harness; use warpui::{App, EntityId}; use crate::{ @@ -89,6 +90,78 @@ fn create_exchange_with_query( } } +#[test] +fn start_new_child_conversation_persists_harness_metadata() { + App::test((), |mut app| async move { + let terminal_view_id = EntityId::new(); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + + let (child_a, child_b, child_ids) = history_model.update(&mut app, |history_model, ctx| { + let parent_conversation_id = + history_model.start_new_conversation(terminal_view_id, false, false, ctx); + history_model.set_server_conversation_token_for_conversation( + parent_conversation_id, + "parent-agent-id".to_string(), + ); + let child_a = history_model.start_new_child_conversation( + terminal_view_id, + "Agent 1".to_string(), + parent_conversation_id, + Some(Harness::Claude), + ctx, + ); + let child_b = history_model.start_new_child_conversation( + terminal_view_id, + "Agent 2".to_string(), + parent_conversation_id, + Some(Harness::Codex), + ctx, + ); + ( + child_a, + child_b, + history_model + .child_conversation_ids_of(&parent_conversation_id) + .to_vec(), + ) + }); + + assert_eq!(child_ids, vec![child_a, child_b]); + history_model.read(&app, |history_model, _| { + let child_a_conversation = history_model + .conversation(&child_a) + .expect("child conversation should exist"); + let child_b_conversation = history_model + .conversation(&child_b) + .expect("child conversation should exist"); + assert_eq!( + child_a_conversation.orchestration_harness_type(), + Some(Harness::Claude.config_name()) + ); + assert_eq!( + child_a_conversation.orchestration_harness(), + Some(Harness::Claude) + ); + assert_eq!( + child_b_conversation.orchestration_harness_type(), + Some(Harness::Codex.config_name()) + ); + assert_eq!( + child_b_conversation.orchestration_harness(), + Some(Harness::Codex) + ); + assert_eq!( + child_a_conversation.parent_agent_id(), + Some("parent-agent-id") + ); + assert_eq!( + child_b_conversation.parent_agent_id(), + Some("parent-agent-id") + ); + }); + }); +} + #[test] fn test_ai_queries_for_terminal_view_up_arrow_history() { App::test((), |mut app| async move { @@ -1173,6 +1246,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1364,6 +1438,7 @@ fn test_fork_then_bind_handoff_token_resolves_to_forked_conversation() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1460,6 +1535,7 @@ fn test_fork_conversation_preserves_task_ids_when_requested() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs index cfeed98576..3bd2427d12 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs @@ -141,6 +141,7 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/ai/blocklist/orchestration_events_tests.rs b/app/src/ai/blocklist/orchestration_events_tests.rs index 30055c0db9..eff054b025 100644 --- a/app/src/ai/blocklist/orchestration_events_tests.rs +++ b/app/src/ai/blocklist/orchestration_events_tests.rs @@ -414,6 +414,7 @@ fn test_restored_v1_child_reregisters_lifecycle_subscription() { terminal_view_id, "child".to_string(), parent_conversation_id, + None, ctx, ); history_model.set_server_conversation_token_for_conversation( diff --git a/app/src/ai/conversation_details_panel_tests.rs b/app/src/ai/conversation_details_panel_tests.rs index 1e1c3ee4c5..308ba3aea6 100644 --- a/app/src/ai/conversation_details_panel_tests.rs +++ b/app/src/ai/conversation_details_panel_tests.rs @@ -129,6 +129,7 @@ fn test_from_task_includes_linked_directory_when_run_id_matches() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: Some(task_id.to_string()), @@ -246,6 +247,7 @@ fn test_from_conversation_populates_local_conversation_fields() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, run_id: None, autoexecute_override: None, @@ -317,6 +319,7 @@ fn test_from_task_includes_linked_directory_when_server_token_matches() { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/pane_group/child_agent.rs b/app/src/pane_group/child_agent.rs index 6d678115c3..8945fca094 100644 --- a/app/src/pane_group/child_agent.rs +++ b/app/src/pane_group/child_agent.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, ffi::OsString, path::PathBuf}; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::attachment_utils::attachments_download_dir; +use warp_cli::agent::Harness; use warpui::{EntityId, SingletonEntity, ViewContext, ViewHandle}; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; @@ -23,6 +24,15 @@ pub(crate) struct HiddenChildAgentTaskContext { pub working_dir: Option, } +pub(crate) struct HiddenChildAgentConversationRequest { + pub parent_pane_id: PaneId, + pub name: String, + pub parent_conversation_id: AIConversationId, + pub orchestration_harness: Option, + pub env_vars: HashMap, + pub task_context: Option, +} + pub(crate) fn apply_hidden_child_agent_task_context( terminal_view: &ViewHandle, task_context: &HiddenChildAgentTaskContext, @@ -81,6 +91,7 @@ fn start_new_child_conversation( terminal_view_id: EntityId, name: String, parent_conversation_id: AIConversationId, + orchestration_harness: Option, ctx: &mut ViewContext, ) -> AIConversationId { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { @@ -88,6 +99,7 @@ fn start_new_child_conversation( terminal_view_id, name, parent_conversation_id, + orchestration_harness, ctx, ) }) @@ -95,13 +107,17 @@ fn start_new_child_conversation( pub(crate) fn create_hidden_child_agent_conversation( group: &mut PaneGroup, - parent_pane_id: PaneId, - name: String, - parent_conversation_id: AIConversationId, - env_vars: HashMap, - task_context: Option, + request: HiddenChildAgentConversationRequest, ctx: &mut ViewContext, ) -> Option { + let HiddenChildAgentConversationRequest { + parent_pane_id, + name, + parent_conversation_id, + orchestration_harness, + env_vars, + task_context, + } = request; let new_pane_id = group.insert_terminal_pane_hidden_for_child_agent(parent_pane_id, env_vars, ctx); let Some(new_terminal_view) = group.terminal_view_from_pane_id(new_pane_id, ctx) else { @@ -116,8 +132,13 @@ pub(crate) fn create_hidden_child_agent_conversation( apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); } - let conversation_id = - start_new_child_conversation(terminal_view_id, name, parent_conversation_id, ctx); + let conversation_id = start_new_child_conversation( + terminal_view_id, + name, + parent_conversation_id, + orchestration_harness, + ctx, + ); group .child_agent_panes @@ -135,6 +156,7 @@ fn create_error_child_agent_conversation_context( parent_pane_id: PaneId, name: String, parent_conversation_id: AIConversationId, + orchestration_harness: Option, ctx: &mut ViewContext, ) -> Option<(Option>, EntityId, AIConversationId)> { if let Some(HiddenChildAgentConversation { @@ -144,11 +166,14 @@ fn create_error_child_agent_conversation_context( .. }) = create_hidden_child_agent_conversation( group, - parent_pane_id, - name.clone(), - parent_conversation_id, - HashMap::new(), - None, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: name.clone(), + parent_conversation_id, + orchestration_harness, + env_vars: HashMap::new(), + task_context: None, + }, ctx, ) { return Some((Some(terminal_view), terminal_view_id, conversation_id)); @@ -156,8 +181,13 @@ fn create_error_child_agent_conversation_context( let parent_terminal_view = group.terminal_view_from_pane_id(parent_pane_id, ctx)?; let parent_terminal_view_id = parent_terminal_view.id(); - let conversation_id = - start_new_child_conversation(parent_terminal_view_id, name, parent_conversation_id, ctx); + let conversation_id = start_new_child_conversation( + parent_terminal_view_id, + name, + parent_conversation_id, + orchestration_harness, + ctx, + ); Some((None, parent_terminal_view_id, conversation_id)) } @@ -166,6 +196,7 @@ pub(crate) fn create_error_child_agent_conversation( parent_pane_id: PaneId, name: String, parent_conversation_id: AIConversationId, + orchestration_harness: Option, error_message: String, ctx: &mut ViewContext, ) { @@ -175,6 +206,7 @@ pub(crate) fn create_error_child_agent_conversation( parent_pane_id, name, parent_conversation_id, + orchestration_harness, ctx, ) else { diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index f05a186414..b6d42ef8b6 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -79,7 +79,10 @@ use uuid::Uuid; use warp_core::features::FeatureFlag; use watcher::HomeDirectoryWatcher; -use super::child_agent::{create_hidden_child_agent_conversation, HiddenChildAgentTaskContext}; +use super::child_agent::{ + create_hidden_child_agent_conversation, HiddenChildAgentConversationRequest, + HiddenChildAgentTaskContext, +}; use super::*; use crate::terminal::resizable_data::ResizableData; use ai::{ @@ -482,14 +485,17 @@ fn test_hidden_child_creation_applies_ambient_task_id_to_controller() { let child = create_hidden_child_agent_conversation( panes, - parent_pane_id, - "Agent 1".to_string(), - parent_conversation_id, - HashMap::new(), - Some(HiddenChildAgentTaskContext { - task_id, - working_dir: None, - }), + HiddenChildAgentConversationRequest { + parent_pane_id, + name: "Agent 1".to_string(), + parent_conversation_id, + orchestration_harness: None, + env_vars: HashMap::new(), + task_context: Some(HiddenChildAgentTaskContext { + task_id, + working_dir: None, + }), + }, ctx, ) .expect("fresh hidden child conversation should be created"); diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 0639ac8be4..8002e6ada4 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -30,7 +30,7 @@ use crate::{ app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}, pane_group::child_agent::{ create_error_child_agent_conversation, create_hidden_child_agent_conversation, - HiddenChildAgentConversation, + HiddenChildAgentConversation, HiddenChildAgentConversationRequest, }, pane_group::{self, Direction, Event::OpenConversationHistory, PaneGroup}, persistence::{BlockCompleted, ModelEvent}, @@ -1205,6 +1205,7 @@ fn dispatch_start_agent_conversation( parent_pane_id, request.name, request.parent_conversation_id, + None, "Local harness child agents are not supported in WASM builds.".to_string(), ctx, ); @@ -1256,11 +1257,14 @@ fn launch_local_no_harness_child( .. } = create_hidden_child_agent_conversation( group, - parent_pane_id, - request.name, - request.parent_conversation_id, - HashMap::new(), - None, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: request.name, + parent_conversation_id: request.parent_conversation_id, + orchestration_harness: Some(Harness::Oz), + env_vars: HashMap::new(), + task_context: None, + }, ctx, )?; @@ -1316,6 +1320,8 @@ fn launch_local_harness_child( let parent_run_id = request.parent_run_id.clone(); let prompt = request.prompt.clone(); let lifecycle_subscription = request.lifecycle_subscription.clone(); + let orchestration_harness = + Harness::parse_orchestration_harness(&harness_type).unwrap_or(Harness::Unknown); let shell_type = group .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|terminal_view| terminal_view.as_ref(ctx).active_session_shell_type(ctx)); @@ -1347,11 +1353,14 @@ fn launch_local_harness_child( .. }) = create_hidden_child_agent_conversation( group, - parent_pane_id, - request_name.clone(), - parent_conversation_id, - env_vars, - None, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: request_name.clone(), + parent_conversation_id, + orchestration_harness: Some(orchestration_harness), + env_vars, + task_context: None, + }, ctx, ) { apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); @@ -1396,6 +1405,7 @@ fn launch_local_harness_child( parent_pane_id, request_name, parent_conversation_id, + Some(orchestration_harness), "Failed to create a hidden pane for the local child harness.".to_string(), ctx, ); @@ -1407,6 +1417,7 @@ fn launch_local_harness_child( parent_pane_id, request_name, parent_conversation_id, + Some(orchestration_harness), error_message, ctx, ); @@ -1458,6 +1469,11 @@ fn launch_remote_child( } = fields; let request_id = request.id; + let orchestration_harness = if harness_type.trim().is_empty() { + Harness::Oz + } else { + Harness::parse_orchestration_harness(&harness_type).unwrap_or(Harness::Unknown) + }; let Some(parent_run_id) = request.parent_run_id.clone() else { log::error!( "Remote StartAgent request missing parent_run_id for {:?}", @@ -1480,6 +1496,7 @@ fn launch_remote_child( terminal_view_id, request.name, request.parent_conversation_id, + Some(orchestration_harness), ctx, ); // Mark as remote so the parent's TaskStatusSyncModel skips status diff --git a/app/src/terminal/view/load_ai_conversation.rs b/app/src/terminal/view/load_ai_conversation.rs index 3216689263..186bcc4a96 100644 --- a/app/src/terminal/view/load_ai_conversation.rs +++ b/app/src/terminal/view/load_ai_conversation.rs @@ -1010,6 +1010,7 @@ impl TerminalView { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 482aaa6c6d..5e8e907e3f 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -4626,6 +4626,7 @@ fn cli_session_status_updates_active_child_conversation() { view.view_id, "Agent 2".to_string(), parent_conversation_id, + None, ctx, ) }); @@ -4773,6 +4774,7 @@ fn cli_session_status_updates_single_child_conversation_without_agent_view() { view.view_id, "Agent 2".to_string(), parent_conversation_id, + None, ctx, ) }); diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index b9ad547e16..7c17dc477d 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -178,7 +178,7 @@ fn agent_icon_variant_from_terminal_inputs( /// [`IconWithStatusVariant`]. Falls back to the Oz variant for [`Harness::Oz`] and /// [`Harness::Unknown`], the latter so a future-server harness this client doesn't /// recognize doesn't render an unbranded gray circle. -fn agent_icon_variant_for_run( +pub(crate) fn agent_icon_variant_for_run( harness: Harness, status: ConversationStatus, is_ambient: bool, diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index 793c32d1ff..bf70ad7c2d 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -1030,6 +1030,13 @@ pub struct AgentConversationData { /// The display name for this agent, assigned by the orchestrator. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_name: Option, + /// Harness type used to render the child agent's shared icon in orchestration UI. + #[serde( + default, + alias = "orchestration_avatar_id", + skip_serializing_if = "Option::is_none" + )] + pub orchestration_harness_type: Option, /// The local conversation ID of the parent conversation. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_conversation_id: Option, @@ -1354,6 +1361,7 @@ mod tests { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: Some("claude".to_string()), parent_conversation_id: None, is_remote_child: false, run_id: None, @@ -1363,6 +1371,19 @@ mod tests { let json = serde_json::to_string(&data).expect("serialize"); let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize"); assert_eq!(roundtripped.last_event_sequence, Some(42)); + assert_eq!( + roundtripped.orchestration_harness_type.as_deref(), + Some("claude") + ); + } + + #[test] + fn agent_conversation_data_accepts_legacy_orchestration_avatar_id() { + let legacy_json = r#"{"orchestration_avatar_id":"orbit"}"#; + let data: AgentConversationData = + serde_json::from_str(legacy_json).expect("legacy rows must deserialize"); + + assert_eq!(data.orchestration_harness_type.as_deref(), Some("orbit")); } #[test] @@ -1375,6 +1396,7 @@ mod tests { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: true, run_id: None, @@ -1394,6 +1416,7 @@ mod tests { let data: AgentConversationData = serde_json::from_str(legacy_json).expect("legacy rows must deserialize"); assert_eq!(data.last_event_sequence, None); + assert_eq!(data.orchestration_harness_type, None); assert!(!data.is_remote_child); } @@ -1407,6 +1430,7 @@ mod tests { artifacts_json: None, parent_agent_id: None, agent_name: None, + orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, run_id: None,