diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 0a7062c2b7f..223962213ec 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -35,7 +35,7 @@ pub use execute::{ ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; use futures::future::{join_all, BoxFuture}; use itertools::Itertools; diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index b01f2333bb7..c77dff385da 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -62,7 +62,8 @@ pub use send_message::SendMessageToAgentExecutor; use serde::{Deserialize, Serialize}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use start_agent::{ - StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, }; use start_recording::StartRecordingExecutor; use stop_recording::StopRecordingExecutor; diff --git a/app/src/ai/blocklist/child_agent_launch.rs b/app/src/ai/blocklist/child_agent_launch.rs new file mode 100644 index 00000000000..998ca708d54 --- /dev/null +++ b/app/src/ai/blocklist/child_agent_launch.rs @@ -0,0 +1,93 @@ +//! Frontend-neutral preparation and settings propagation for local Oz children. +#[cfg(not(target_family = "wasm"))] +use std::future::Future; + +use warpui::{AppContext, EntityId, SingletonEntity as _}; +#[cfg(not(target_family = "wasm"))] +use { + crate::ai::ambient_agents::task::normalize_orchestrator_agent_name, + crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}, + crate::server::server_api::ServerApiProvider, +}; + +use crate::ai::llms::{LLMId, LLMPreferences}; +use crate::AIExecutionProfilesModel; + +/// Server-side state prepared before a frontend creates the child's surface. +#[cfg(not(target_family = "wasm"))] +pub struct PreparedLocalOzChildLaunch { + pub task_id: AmbientAgentTaskId, + pub conversation_name: String, +} + +/// Creates the server task row shared by the GUI hidden-pane and TUI +/// background-session launch paths. +#[cfg(not(target_family = "wasm"))] +pub fn prepare_local_oz_child_launch( + name: &str, + prompt: &str, + parent_run_id: Option<&str>, + ctx: &AppContext, +) -> impl Future> + 'static { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let agent_name = normalize_orchestrator_agent_name(name); + let conversation_name = agent_name.clone().unwrap_or_default(); + let prompt = prompt.to_owned(); + let parent_run_id = parent_run_id.map(str::to_owned); + async move { + let task_id = ai_client + .create_agent_task( + prompt, + None, + parent_run_id, + Some(AgentConfigSnapshot { + name: agent_name, + ..Default::default() + }), + ) + .await?; + Ok(PreparedLocalOzChildLaunch { + task_id, + conversation_name, + }) + } +} + +/// Copies the parent's execution profile and effective base model to a child +/// surface before its first request is sent. +pub fn inherit_child_agent_settings( + parent_surface_id: EntityId, + child_surface_id: EntityId, + ctx: &mut AppContext, +) { + let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) + .active_profile(Some(parent_surface_id), ctx) + .id(); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { + profiles.set_active_profile(child_surface_id, parent_profile_id, ctx); + }); + + let parent_base_model_id = LLMPreferences::as_ref(ctx) + .get_active_base_model(ctx, Some(parent_surface_id)) + .id + .clone(); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); + }); +} + +/// Applies a non-empty run-wide model override after parent settings have +/// been inherited. +pub fn apply_child_agent_model_override( + child_surface_id: EntityId, + model_id: Option<&str>, + ctx: &mut AppContext, +) { + let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) else { + return; + }; + let model_id = LLMId::from(model_id); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.set_agent_mode_llm_override(child_surface_id, model_id, ctx); + }); +} diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index b87cfbb95a9..c3254bdd863 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -2180,6 +2180,10 @@ impl BlocklistAIHistoryModel { self.all_conversations_metadata.remove(&conversation_id); self.conversations_by_id.remove(&conversation_id); + self.children_by_parent.retain(|_, child_ids| { + child_ids.retain(|child_id| *child_id != conversation_id); + !child_ids.is_empty() + }); if let Some(terminal_surface_id) = terminal_surface_id { if self diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 9157686d2b2..ed57f5f61de 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -1926,6 +1926,32 @@ fn test_set_parent_multiple_children() { }); } +#[test] +fn test_remove_child_conversation_cleans_parent_index() { + App::test((), |mut app| async move { + let terminal_view_id = EntityId::new(); + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let parent_id = history_model.update(&mut app, |model, ctx| { + model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let child_id = history_model.update(&mut app, |model, ctx| { + let child_id = model.start_new_conversation(terminal_view_id, false, false, false, ctx); + model.set_parent_for_conversation(child_id, parent_id); + child_id + }); + + history_model.update(&mut app, |model, ctx| { + model.remove_conversation(child_id, terminal_view_id, ctx); + }); + + history_model.read(&app, |model, _| { + assert!(model.conversation(&child_id).is_none()); + assert!(model.child_conversation_ids_of(&parent_id).is_empty()); + }); + }); +} + #[test] fn test_child_conversation_ids_of_unknown_parent() { App::test((), |app| async move { diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index fe29f4b09b2..b2ecf440c7f 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -2,6 +2,7 @@ mod action_model; pub mod agent_view; pub mod block; +mod child_agent_launch; pub mod code_block; mod context_model; mod controller; @@ -49,8 +50,7 @@ pub use action_model::RequestFileEditsExecutor; #[cfg_attr(target_family = "wasm", allow(unused_imports))] pub(crate) use action_model::{ apply_edits, read_local_file_context, FileReadResult, ReadFileContextResult, - RequestFileEditsFormatKind, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + RequestFileEditsFormatKind, }; pub use action_model::{ BlocklistAIActionEvent, BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent, @@ -58,10 +58,24 @@ pub use action_model::{ // Consumed by `tui_export` for the `warp_tui` frontend. #[cfg_attr(not(feature = "tui"), allow(unused_imports))] pub use action_model::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot}; +// Consumed by `tui_export` for the `warp_tui` frontend's child-agent +// materializer, in addition to the GUI pane-group dispatch. +#[cfg_attr( + any(target_family = "wasm", not(feature = "tui")), + allow(unused_imports) +)] +pub use action_model::{ + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, +}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; pub use block::{keyboard_navigable_buttons, toggleable_items}; +pub use child_agent_launch::{apply_child_agent_model_override, inherit_child_agent_settings}; +#[cfg(not(target_family = "wasm"))] +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use child_agent_launch::{prepare_local_oz_child_launch, PreparedLocalOzChildLaunch}; #[cfg(not(feature = "tui"))] pub(crate) use context_model::block_context_from_terminal_model; #[cfg(feature = "tui")] diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 56f1880a8b9..db173b281fe 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -854,14 +854,6 @@ impl LLMPreferences { app: &AppContext, terminal_view_id: Option, ) -> &LLMInfo { - // In the TUI, the file-backed `agents.model` setting is the source of - // truth for the base model: it overrides both per-surface overrides - // and the cloud-synced execution profile, keeping the TUI's TOML file - // the single place the model is configured. - if settings_mode == settings::SettingsMode::Tui { - return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); - } - if let Some(terminal_view_id) = terminal_view_id { let raw_override = self.base_llm_for_terminal_view.get(&terminal_view_id); if let Some(llm_id) = raw_override { @@ -873,6 +865,12 @@ impl LLMPreferences { } } + // In the TUI, the file-backed `agents.model` setting is the default + // for every surface. Explicit per-surface overrides still take + // precedence for orchestrated children launched with a model override. + if settings_mode == settings::SettingsMode::Tui { + return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); + } let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); profile @@ -1497,8 +1495,8 @@ impl LLMPreferences { .is_some() } else { self.base_llm_for_terminal_view - .insert(terminal_view_id, preferred_llm_id.clone()); - true + .insert(terminal_view_id, preferred_llm_id.clone()) + != Some(preferred_llm_id.clone()) }; if changed { @@ -1507,6 +1505,26 @@ impl LLMPreferences { } } + /// Sets an explicit runtime override without normalizing it against the + /// execution profile. Orchestrated child runs use this because a requested + /// model equal to the profile default must still override the TUI's + /// file-backed model setting. + pub(crate) fn set_agent_mode_llm_override( + &mut self, + terminal_view_id: EntityId, + model_id: LLMId, + ctx: &mut ModelContext, + ) { + if self + .base_llm_for_terminal_view + .insert(terminal_view_id, model_id.clone()) + != Some(model_id) + { + self.trigger_snapshot_save(ctx); + ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); + } + } + /// Copies the raw per-pane Agent Mode override from `source_terminal_view_id` /// onto `new_terminal_view_id`, removing any existing override when the /// source has none. Combined with copying the source's execution profile, diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 5b46fae9c18..c2227f6914d 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -932,6 +932,29 @@ fn tui_agent_model_known_id_resolves_to_that_model() { }); } +#[test] +fn tui_surface_override_precedes_file_backed_default() { + App::test((), |app| async move { + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(UserWorkspaces::default_mock); + app.read(|app_ctx| { + let surface_id = EntityId::new(); + let mut preferences = preferences_for_tui_tests(); + preferences + .base_llm_for_terminal_view + .insert(surface_id, LLMId::from("auto")); + + let info = preferences.get_preferred_base_model_for_settings_mode( + settings::SettingsMode::Tui, + app_ctx, + Some(surface_id), + ); + + assert_eq!(info.id.as_str(), "auto"); + }); + }); +} + #[test] fn tui_agent_model_unknown_id_falls_back_to_the_default_model() { tui_agent_model_test(|preferences, app| { diff --git a/app/src/global_resource_handles.rs b/app/src/global_resource_handles.rs index bedbdef1a3d..9ed6e43d341 100644 --- a/app/src/global_resource_handles.rs +++ b/app/src/global_resource_handles.rs @@ -58,7 +58,7 @@ pub struct GlobalResourceHandles { } impl GlobalResourceHandles { - #[cfg(any(test, feature = "integration_tests"))] + #[cfg(any(test, feature = "integration_tests", feature = "test-util"))] pub fn mock(app: &mut warpui::App) -> Self { let referral_theme_status = app.add_model(ReferralThemeStatus::new); let user_default_shell_unsupported_banner_model_handle = diff --git a/app/src/pane_group/child_agent/mod.rs b/app/src/pane_group/child_agent/mod.rs index ea22d1864c2..ac050925928 100644 --- a/app/src/pane_group/child_agent/mod.rs +++ b/app/src/pane_group/child_agent/mod.rs @@ -14,12 +14,12 @@ use crate::ai::agent::RenderableAIError; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::attachment_utils::attachments_download_dir; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequestId}; -use crate::ai::llms::LLMPreferences; +use crate::ai::blocklist::{ + inherit_child_agent_settings, BlocklistAIHistoryModel, StartAgentRequestId, +}; use crate::pane_group::{PaneGroup, PaneId}; use crate::terminal::shared_session::IsSharedSessionCreator; use crate::terminal::TerminalView; -use crate::AIExecutionProfilesModel; pub(crate) struct HiddenChildAgentConversation { pub terminal_view: ViewHandle, @@ -75,40 +75,6 @@ pub(crate) fn apply_hidden_child_agent_task_context( }); } -fn propagate_parent_agent_settings( - group: &PaneGroup, - parent_pane_id: PaneId, - child_terminal_view_id: EntityId, - ctx: &mut ViewContext, -) { - let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) else { - log::warn!( - "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" - ); - return; - }; - - let parent_view_id = parent_terminal_view.id(); - let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) - .active_profile(Some(parent_view_id), ctx) - .id(); - AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { - profiles.set_active_profile(child_terminal_view_id, parent_profile_id, ctx); - }); - - let parent_base_model_id = LLMPreferences::as_ref(ctx) - .get_active_base_model(ctx, Some(parent_view_id)) - .id - .clone(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm( - &parent_base_model_id, - child_terminal_view_id, - ctx, - ); - }); -} - fn start_new_child_conversation( terminal_view_id: EntityId, name: String, @@ -154,7 +120,13 @@ pub(crate) fn create_hidden_child_agent_conversation( }; let terminal_view_id = new_terminal_view.id(); - propagate_parent_agent_settings(group, parent_pane_id, terminal_view_id, ctx); + if let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) { + inherit_child_agent_settings(parent_terminal_view.id(), terminal_view_id, ctx); + } else { + log::warn!( + "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" + ); + } if let Some(task_context) = task_context.as_ref() { apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 0a37b6aebda..8e82d9ffc7c 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -29,9 +29,13 @@ use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, Harness use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::prepare_local_oz_child_launch; #[cfg(feature = "local_fs")] use crate::ai::blocklist::BlocklistAIHistoryEvent; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; +use crate::ai::blocklist::{ + apply_child_agent_model_override, BlocklistAIHistoryModel, StartAgentRequest, +}; use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; use crate::ai::skills::SkillManager; @@ -122,23 +126,6 @@ fn serialize_proto_to_base64(message: &M) -> String { BASE64_STANDARD.encode(message.encode_to_vec()) } -/// Overrides the child's preferred agent-mode LLM. `None` is a no-op -/// (inherits the parent's LLM via `propagate_parent_agent_settings`). -#[cfg(not(target_family = "wasm"))] -fn apply_child_model_id_override( - child_terminal_view_id: EntityId, - model_id: Option<&str>, - ctx: &mut ViewContext, -) { - let Some(model_id) = model_id.map(str::trim).filter(|m| !m.is_empty()) else { - return; - }; - let llm_id: ai::LLMId = model_id.into(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm(&llm_id, child_terminal_view_id, ctx); - }); -} - /// Returns the host terminal's `SharedSessionSource`, or `None` if it is /// not currently a shared-session creator. Reads the underlying /// `TerminalModel` directly via the host's `TerminalView`. @@ -1638,12 +1625,8 @@ fn launch_local_no_harness_child( model_id: Option, ctx: &mut ViewContext, ) { - let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client(); let request_id = request.id; - let agent_name = normalize_orchestrator_agent_name(&request.name); - let request_name = agent_name.clone().unwrap_or_default(); let parent_conversation_id = request.parent_conversation_id; - let parent_run_id = request.parent_run_id.clone(); let prompt = request.prompt.clone(); // Snapshot the host terminal's shared-session source before the spawn @@ -1653,118 +1636,107 @@ fn launch_local_no_harness_child( .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); - let prompt_for_create = prompt.clone(); - let agent_name_for_create = agent_name.clone(); - let _ = ctx.spawn( - async move { - ai_client - .create_agent_task( - prompt_for_create, - None, - parent_run_id, - Some(AgentConfigSnapshot { - name: agent_name_for_create, - ..Default::default() + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, + ); + let _ = ctx.spawn(launch, move |group, result, ctx| match result { + Ok(prepared) => { + let child_task_id = prepared.task_id; + let is_shared_session_creator = + inherit_share_for_local_child(host_source.as_ref(), child_task_id); + + if let Some(HiddenChildAgentConversation { + terminal_view: new_terminal_view, + terminal_view_id, + conversation_id, + .. + }) = create_hidden_child_agent_conversation( + group, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: prepared.conversation_name.clone(), + parent_conversation_id, + orchestration_harness: Some(Harness::Oz), + env_vars: HashMap::new(), + task_context: Some(HiddenChildAgentTaskContext { + task_id: child_task_id, + working_dir: None, }), - ) - .await - }, - move |group, result, ctx| match result { - Ok(child_task_id) => { - let is_shared_session_creator = - inherit_share_for_local_child(host_source.as_ref(), child_task_id); - - if let Some(HiddenChildAgentConversation { - terminal_view: new_terminal_view, - terminal_view_id, - conversation_id, - .. - }) = create_hidden_child_agent_conversation( - group, - HiddenChildAgentConversationRequest { - parent_pane_id, - name: request_name.clone(), - parent_conversation_id, - orchestration_harness: Some(Harness::Oz), - env_vars: HashMap::new(), - task_context: Some(HiddenChildAgentTaskContext { - task_id: child_task_id, - working_dir: None, - }), - is_shared_session_creator, - }, - ctx, - ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); - - // Stamp the task id on the child conversation directly - // so the share-reporter in - // `local_tty/terminal_manager.rs` can resolve it from - // the selected conversation when the share handshake - // succeeds. Mirrors the pattern used by - // `OrchestrationViewerModel::apply_children_fetch`. - BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { - if let Some(conversation) = model.conversation_mut(&conversation_id) { - conversation.set_task_id(child_task_id); - } - model.record_new_conversation_request_complete( - request_id, - conversation_id, - ctx, - ); - }); - - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view - .ai_controller() - .update(ctx, |controller, ctx| { - controller.send_agent_query_in_conversation( - prompt.clone(), - conversation_id, - ctx, - ); - }); + is_shared_session_creator, + }, + ctx, + ) { + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); + + // Stamp the task id on the child conversation directly + // so the share-reporter in + // `local_tty/terminal_manager.rs` can resolve it from + // the selected conversation when the share handshake + // succeeds. Mirrors the pattern used by + // `OrchestrationViewerModel::apply_children_fetch`. + BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { + if let Some(conversation) = model.conversation_mut(&conversation_id) { + conversation.set_task_id(child_task_id); + } + model.record_new_conversation_request_complete( + request_id, + conversation_id, + ctx, + ); + }); - terminal_view.enter_agent_view( - None, - Some(conversation_id), - AgentViewEntryOrigin::ChildAgent, - ctx, - ); - }); - } else { - let _ = create_error_child_agent_conversation( - group, - ErrorChildAgentConversationRequest { - parent_pane_id, - name: request_name, - parent_conversation_id, - request_id: Some(request_id), - orchestration_harness: Some(Harness::Oz), - error_message: - "Failed to create a hidden pane for the local child agent." - .to_string(), - }, + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view + .ai_controller() + .update(ctx, |controller, ctx| { + controller.send_agent_query_in_conversation( + prompt.clone(), + conversation_id, + ctx, + ); + }); + + terminal_view.enter_agent_view( + None, + Some(conversation_id), + AgentViewEntryOrigin::ChildAgent, ctx, ); - } - } - Err(error) => { + }); + } else { let _ = create_error_child_agent_conversation( group, ErrorChildAgentConversationRequest { parent_pane_id, - name: request_name, + name: prepared.conversation_name, parent_conversation_id, request_id: Some(request_id), orchestration_harness: Some(Harness::Oz), - error_message: format!("Failed to create local child task: {error}"), + error_message: "Failed to create a hidden pane for the local child agent." + .to_string(), }, ctx, ); } - }, - ); + } + Err(error) => { + let _ = create_error_child_agent_conversation( + group, + ErrorChildAgentConversationRequest { + parent_pane_id, + name: normalize_orchestrator_agent_name(&request.name).unwrap_or_default(), + parent_conversation_id, + request_id: Some(request_id), + orchestration_harness: Some(Harness::Oz), + error_message: format!("Failed to create local child task: {error}"), + }, + ctx, + ); + } + }); } /// Asynchronously prepares a local harness launch, then creates the @@ -1844,7 +1816,7 @@ fn launch_local_harness_child( }, ctx, ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { model.record_new_conversation_request_complete( diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 79f53709438..38bdd5e363f 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -21,10 +21,11 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, RequestCommandOutputResult, RunAgentsAgentOutcomeKind, - RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, - ShellCommandDelay, StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, - TodoOperation, UserQueryMode, + FileGlobV2Result, GrepResult, MessageId, ReceivedMessageDisplay, RenderableAIError, + RequestCommandOutputResult, RunAgentsAgentOutcomeKind, RunAgentsResult, + SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandDelay, + StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, TodoOperation, + UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ query_conversation_entries, AgentConversationEntry, AgentConversationEntryId, @@ -32,6 +33,7 @@ pub use crate::ai::agent_conversations_model::{ AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, OwnerFilter, }; +pub use crate::ai::ambient_agents::AmbientAgentTaskId; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, EphemeralMessageModel, @@ -56,13 +58,19 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +pub use crate::ai::blocklist::orchestration_event_streamer::{ + register_agent_event_consumer, unregister_agent_event_consumer, +}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ - block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, - BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, - InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, + apply_child_agent_model_override, block_context_from_terminal_model, + inherit_child_agent_settings, prepare_local_oz_child_launch, AIActionStatus, + BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, + InputTypeAutoDetectionSource, PolicyConfigUpdate, PreparedLocalOzChildLaunch, + RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, + ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, + StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs index 5819810fbd8..212441acdf5 100644 --- a/app/src/tui_test_support.rs +++ b/app/src/tui_test_support.rs @@ -85,6 +85,10 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) }); app.add_singleton_model(AgentConversationsModel::new); + let global_resources = crate::GlobalResourceHandles::mock(app); + app.add_singleton_model(|_| { + crate::GlobalResourceHandlesProvider::new(global_resources.clone()) + }); app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); app.add_singleton_model(|ctx| { diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index e8e59e0488e..40cb2b80386 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -983,7 +983,10 @@ impl TuiAIBlock { ); } AIAgentOutputMessageType::Action(action) => { - sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + // WaitForEvents renders nothing, matching the GUI. + if !matches!(action.action, AIAgentActionType::WaitForEvents { .. }) { + sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + } } AIAgentOutputMessageType::Reasoning { text, @@ -1035,6 +1038,26 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, + + // TODO: add full status rendering for sub-agents. + AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { + for received in messages { + sections.push(TuiAIBlockSection::RichText( + TuiRichTextSection::PlainText(format!( + "Received message from agent {}: {}", + received.sender_agent_id, received.subject + )), + )); + } + } + AIAgentOutputMessageType::EventsFromAgents { event_ids } => { + let count = event_ids.len(); + let plural = if count == 1 { "" } else { "s" }; + sections.push(TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( + format!("Received {count} agent lifecycle event{plural}"), + ))); + } + // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) @@ -1043,9 +1066,7 @@ impl TuiAIBlock { | AIAgentOutputMessageType::CommentsAddressed { .. } | AIAgentOutputMessageType::DebugOutput { .. } | AIAgentOutputMessageType::ArtifactCreated(_) - | AIAgentOutputMessageType::SkillInvoked(_) - | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } - | AIAgentOutputMessageType::EventsFromAgents { .. } => {} + | AIAgentOutputMessageType::SkillInvoked(_) => {} } } } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index ef90a088808..ad11e10032e 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -15,8 +15,9 @@ use warp::tui_export::{ AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, - MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, - SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, + MessageId, OutputStatusUpdateCallback, ReceivedMessageDisplay, RequestCommandOutputResult, + ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, + UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; @@ -314,6 +315,72 @@ fn agent_block_renders_multiple_tool_calls_in_order() { }); } +#[test] +fn orchestration_outputs_render_without_wait_for_events_tool_row() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage { + id: MessageId::new("m2".to_string()), + message: AIAgentOutputMessageType::MessagesReceivedFromAgents { + messages: vec![ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }], + }, + citations: Vec::new(), + }, + AIAgentOutputMessage { + id: MessageId::new("m3".to_string()), + message: AIAgentOutputMessageType::EventsFromAgents { + event_ids: vec!["event-1".to_string(), "event-2".to_string()], + }, + citations: Vec::new(), + }, + ]), + }, + ); + + app.read(|app_ctx| { + let block = block.as_ref(app_ctx); + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::PlainText( + "Received message from agent researcher: Investigation complete" + .to_string(), + ), + TuiAIBlockSection::PlainText("Received 2 agent lifecycle events".to_string(),), + ], + ); + assert_eq!( + render_block_lines(block, 80, app_ctx), + vec![ + "Received message from agent researcher: Investigation complete", + "Received 2 agent lifecycle events", + ], + ); + }); + }); +} + #[test] fn tool_call_row_glyph_and_colors_reflect_state() { App::test((), |app| async move { diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 6543e718d54..a753074bf37 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -34,6 +34,7 @@ mod model_menu; mod option_selector; mod orchestrated_agent_identity_styling; mod orchestration_block; +mod orchestration_model; mod resume; mod session_registry; mod skills_menu; diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs new file mode 100644 index 00000000000..7d4ececf0ca --- /dev/null +++ b/crates/warp_tui/src/orchestration_model.rs @@ -0,0 +1,324 @@ +//! [`TuiOrchestrationModel`]: the TUI's child-agent coordinator. +//! +//! The shared `StartAgentExecutor` (one per session surface) emits +//! `CreateAgent` and waits for a frontend to materialize the child. In the +//! GUI that materializer is `TerminalView` → `PaneGroup`'s hidden child +//! panes; in the TUI it is this singleton. It subscribes to every session +//! registered with [`TuiSessions`] (so children can orchestrate +//! grandchildren), spawns native Oz children into background sessions, and +//! tracks the session dimension of the orchestration tree — conversation +//! lineage itself stays in `BlocklistAIHistoryModel`. +//! +//! Native (Oz) local children run in background TUI sessions. Local +//! CLI-harness and remote child requests resolve with an explicit failure. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use warp::tui_export::{ + apply_child_agent_model_override, inherit_child_agent_settings, prepare_local_oz_child_launch, + register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, + BlocklistAIHistoryModel, ConversationStatus, Harness, PreparedLocalOzChildLaunch, + RenderableAIError, StartAgentExecutionMode, StartAgentRequest, +}; +use warpui::SingletonEntity; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle}; + +use crate::session::create_local_terminal_session; +use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; +use crate::terminal_session_view::TuiTerminalSessionEvent; + +/// The TUI's child-agent coordinator singleton. See the module docs. +pub(crate) struct TuiOrchestrationModel { + /// Session hosting each live child conversation. The session dimension + /// only — conversation lineage is read from `BlocklistAIHistoryModel` + /// (`children_by_parent` / `parent_conversation_id`), never mirrored. + child_session_by_conversation: HashMap, + /// Conversations whose event streams are consumed by each live session. + event_consumers_by_session: HashMap>, +} + +impl Entity for TuiOrchestrationModel { + type Event = (); +} + +impl SingletonEntity for TuiOrchestrationModel {} + +impl TuiOrchestrationModel { + /// Registers the singleton and subscribes it to [`TuiSessions`] so every + /// session's `StartAgentExecutor` gets wired as sessions register. Must + /// run before any session is created. + pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { + let sessions = TuiSessions::handle(ctx); + let model = ctx.add_singleton_model(|_| Self { + child_session_by_conversation: HashMap::new(), + event_consumers_by_session: HashMap::new(), + }); + let model_for_sessions = model.clone(); + ctx.subscribe_to_model(&sessions, move |sessions, event, ctx| match event { + TuiSessionsEvent::SessionAdded(session_id) => { + let session_id = *session_id; + let Some(session_view) = sessions + .as_ref(ctx) + .session(session_id) + .map(|session| session.view().clone()) + else { + return; + }; + let model = model_for_sessions.clone(); + ctx.subscribe_to_view(&session_view, move |_, event, ctx| { + model.update(ctx, |model, ctx| { + model.handle_session_event(session_id, event, ctx); + }); + }); + } + TuiSessionsEvent::SessionRemoved(session_id) => { + model_for_sessions.update(ctx, |model, ctx| { + model.handle_session_removed(*session_id, ctx); + }); + } + TuiSessionsEvent::FocusChanged(_) => {} + }); + model + } + + fn handle_session_event( + &mut self, + parent_session_id: TuiSessionId, + event: &TuiTerminalSessionEvent, + ctx: &mut ModelContext, + ) { + match event { + TuiTerminalSessionEvent::StartAgentConversation { + request, + working_directory, + } => { + self.dispatch_create_agent( + parent_session_id, + (**request).clone(), + working_directory.clone(), + ctx, + ); + } + TuiTerminalSessionEvent::CleanupFailedChildLaunch { conversation_id } => { + self.cleanup_failed_child(conversation_id, ctx); + } + TuiTerminalSessionEvent::ExecuteCommand(_) + | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::WriteAgentInput { .. } + | TuiTerminalSessionEvent::WriteUserInput(_) + | TuiTerminalSessionEvent::Resize(_) => {} + } + } + + /// Routes a `CreateAgent` request the same two ways as the GUI's + /// per-mode dispatch, with unsupported modes resolving as clean per-child + /// failures. + fn dispatch_create_agent( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + working_directory: Option, + ctx: &mut ModelContext, + ) { + match request.execution_mode.clone() { + StartAgentExecutionMode::Local { + harness_type: None, + model_id, + } => self.begin_local_oz_child_launch( + parent_session_id, + request, + model_id, + working_directory, + ctx, + ), + StartAgentExecutionMode::Local { + harness_type: Some(harness_type), + .. + } => { + // Local non-oz children are not supported outside of dogfood in the GUI, + // and would be odd in the TUI. For now, we don't offer this option in the + // orchestration card, so this should never be reached. + self.fail_child_request( + &request, + format!( + "Local {harness_type} child agents aren't supported in the Warp TUI yet." + ), + ctx, + ); + } + StartAgentExecutionMode::Remote { .. } => { + // TODO(code-1822): remote children need a TUI materializer; + // the GUI's spawn path is coupled to ambient-agent panes. + self.fail_child_request( + &request, + "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), + ctx, + ); + } + } + } + + /// Starts server-side task creation. The completion callback creates the + /// TUI session only after the task has a stable run id. + fn begin_local_oz_child_launch( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + model_id: Option, + working_directory: Option, + ctx: &mut ModelContext, + ) { + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, + ); + ctx.spawn(launch, move |me, result, ctx| match result { + Ok(prepared) => me.create_local_oz_child_session( + parent_session_id, + &request, + model_id.as_deref(), + working_directory, + prepared, + ctx, + ), + Err(error) => me.fail_child_request( + &request, + format!("Failed to create local child task: {error}"), + ctx, + ), + }); + } + + /// Creates the background terminal session and child conversation for a + /// prepared task, then sends the child's first prompt. + fn create_local_oz_child_session( + &mut self, + parent_session_id: TuiSessionId, + request: &StartAgentRequest, + model_id: Option<&str>, + working_directory: Option, + prepared: PreparedLocalOzChildLaunch, + ctx: &mut ModelContext, + ) { + let sessions = TuiSessions::handle(ctx); + let (session_id, session_view) = + create_local_terminal_session(&sessions, false, working_directory, ctx); + let child_surface_id = session_id.surface_id(); + let task_id = prepared.task_id; + + let parent_surface_id = parent_session_id.surface_id(); + inherit_child_agent_settings(parent_surface_id, child_surface_id, ctx); + apply_child_agent_model_override(child_surface_id, model_id, ctx); + + let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + child_surface_id, + prepared.conversation_name, + request.parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + // Stamp the task id before completing the request so the + // executor and the local task-status sync see it immediately. + if let Some(conversation) = history.conversation_mut(&conversation_id) { + conversation.set_task_id(task_id); + } + history.set_active_conversation_id(conversation_id, child_surface_id, ctx); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + conversation_id + }); + + self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); + self.register_event_consumer(session_id, conversation_id, ctx); + + let prompt = request.prompt.clone(); + session_view.update(ctx, |view, ctx| { + view.start_orchestrated_child(task_id, prompt, conversation_id, ctx); + }); + + self.child_session_by_conversation + .insert(conversation_id, session_id); + ctx.notify(); + } + + /// Tears down the background session of a child that failed at the + /// launch stage (the executor's `CleanupFailedChildLaunch`). + fn cleanup_failed_child( + &mut self, + conversation_id: &AIConversationId, + ctx: &mut ModelContext, + ) { + let terminal_surface_id = BlocklistAIHistoryModel::as_ref(ctx) + .terminal_surface_id_for_conversation(conversation_id); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.delete_conversation(*conversation_id, terminal_surface_id, ctx); + }); + if let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) { + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.remove_session(session_id, ctx); + }); + } + ctx.notify(); + } + + /// Resolves a child request as failed without creating a TUI session. + fn fail_child_request( + &mut self, + request: &StartAgentRequest, + message: String, + ctx: &mut ModelContext, + ) { + log::warn!( + "Failing TUI child agent request '{}': {message}", + request.name + ); + let surface_id = EntityId::new(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + surface_id, + request.name.trim().to_owned(), + request.parent_conversation_id, + None, + ctx, + ); + history.update_conversation_status_with_error( + surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + }); + } + + fn register_event_consumer( + &mut self, + session_id: TuiSessionId, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + register_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + self.event_consumers_by_session + .entry(session_id) + .or_default() + .insert(conversation_id); + } + + fn handle_session_removed(&mut self, session_id: TuiSessionId, ctx: &mut ModelContext) { + if let Some(conversation_ids) = self.event_consumers_by_session.remove(&session_id) { + for conversation_id in conversation_ids { + unregister_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + } + } + self.child_session_by_conversation + .retain(|_, child_session_id| *child_session_id != session_id); + } +} + +#[cfg(test)] +#[path = "orchestration_model_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs new file mode 100644 index 00000000000..40755012919 --- /dev/null +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -0,0 +1,221 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::{App, WindowId}; + +use super::TuiOrchestrationModel; +use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_semantic_selection, add_test_terminal_session, +}; + +struct OrchestrationFixture { + sessions: ModelHandle, + window_id: WindowId, +} + +/// Boots the container + root + orchestration model wiring (no live PTYs). +fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { + register_tui_session_view_test_singletons(app); + add_test_semantic_selection(app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + root.update(app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + }); + app.update(TuiOrchestrationModel::register); + OrchestrationFixture { + sessions, + window_id, + } +} + +/// Registers a session with a live active conversation. +fn add_dispatching_session( + app: &mut App, + fixture: &OrchestrationFixture, + focus: bool, +) -> TuiSessionId { + let (session, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update_model(&fixture.sessions, |sessions, ctx| { + sessions.add_session(session, manager, focus, ctx) + }); + add_active_test_conversation(app, session_id.surface_id()); + session_id +} + +/// Creates a standalone executor and relays its frontend materialization +/// events into the coordinator. +fn add_relayed_executor( + app: &mut App, + parent_session_id: TuiSessionId, +) -> ModelHandle { + let executor = app.add_model(StartAgentExecutor::new); + app.update(|ctx| { + let orchestration = TuiOrchestrationModel::handle(ctx); + ctx.subscribe_to_model(&executor, move |_, event, ctx| { + orchestration.update(ctx, |orchestration, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + orchestration.dispatch_create_agent( + parent_session_id, + (**request).clone(), + None, + ctx, + ); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + orchestration.cleanup_failed_child(conversation_id, ctx); + } + }); + }); + }); + executor +} + +/// Dispatches a StartAgent request through the session's executor and +/// returns the resolved outcome (the orchestration model resolves +/// unsupported modes synchronously within the same effect flush). +fn dispatch_and_recv( + app: &mut App, + session_id: TuiSessionId, + executor: &ModelHandle, + execution_mode: StartAgentExecutionMode, +) -> (AIConversationId, StartAgentOutcome) { + let parent_conversation_id = app.read(|ctx| { + warp::tui_export::BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(session_id.surface_id()) + .expect("fixture registered an active conversation") + .id() + }); + let receiver = app.update_model(executor, |executor, ctx| { + executor.dispatch( + "researcher".to_string(), + "research the codebase".to_string(), + execution_mode, + None, + parent_conversation_id, + Some("parent-run-1".to_string()), + ctx, + ) + }); + ( + parent_conversation_id, + receiver + .try_recv() + .expect("unsupported-mode dispatches resolve before the update returns"), + ) +} + +fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { + match outcome { + StartAgentOutcome::Error(message) => { + assert!(message.contains(needle), "unexpected error: {message}"); + } + StartAgentOutcome::Started { agent_id } => { + panic!("expected an error outcome, got Started({agent_id})"); + } + } +} + +fn assert_failed_launch_cleaned_up( + app: &App, + fixture: &OrchestrationFixture, + parent_conversation_id: AIConversationId, + expected_session_count: usize, +) { + app.read(|ctx| { + let history = BlocklistAIHistoryModel::as_ref(ctx); + assert!(history + .child_conversation_ids_of(&parent_conversation_id) + .is_empty()); + assert!(TuiOrchestrationModel::as_ref(ctx) + .event_consumers_by_session + .is_empty()); + }); + assert_eq!( + app.read_model(&fixture.sessions, |sessions, _| sessions.len()), + expected_session_count, + ); +} +#[test] +fn local_harness_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + session_id, + &executor, + StartAgentExecutionMode::Local { + harness_type: Some("claude".to_string()), + model_id: None, + }, + ); + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); + }); +} + +#[test] +fn remote_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + session_id, + &executor, + StartAgentExecutionMode::Remote { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: false, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Researcher".to_string(), + auth_secret_name: None, + }, + ); + assert_error_containing(outcome, "Cloud child agents aren't supported"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); + }); +} + +#[test] +fn failed_launch_cleanup_preserves_other_sessions() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let _ = add_dispatching_session(&mut app, &fixture, true); + let background_session_id = add_dispatching_session(&mut app, &fixture, false); + let executor = add_relayed_executor(&mut app, background_session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + background_session_id, + &executor, + StartAgentExecutionMode::Local { + harness_type: Some("codex".to_string()), + model_id: None, + }, + ); + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 2); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 9d3d2b1a462..e44e436541f 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -23,9 +23,10 @@ use warpui_core::platform::{TerminationMode, WindowStyle}; use warpui_core::runtime::spawn_tui_driver; use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; +use crate::orchestration_model::TuiOrchestrationModel; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::session_registry::TuiSessions; +use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -128,6 +129,7 @@ fn init( root.update(ctx, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); }); + TuiOrchestrationModel::register(ctx); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { // Already authenticated at mount: create the first session now. create_terminal_session_after_login(&sessions, &root, ctx); @@ -153,7 +155,7 @@ fn init( } } -/// Creates the focused bootstrap session after login. +/// Creates the focused bootstrap session and restores the requested conversation. fn create_terminal_session_after_login( sessions: &ModelHandle, root: &ViewHandle, @@ -164,11 +166,34 @@ fn create_terminal_session_after_login( } let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); + let (_, surface) = + create_local_terminal_session(sessions, true, std::env::current_dir().ok(), ctx); + if let Some(token) = resume_token { + surface.update(ctx, |view, ctx| { + view.restore_conversation( + TuiConversationRestoreTarget::Server(token), + TuiConversationRestoreOrigin::Startup, + ctx, + ); + }); + } + root.update(ctx, |root, ctx| root.show_terminal(ctx)); +} + +/// Creates and registers a full local terminal session. +pub(crate) fn create_local_terminal_session( + sessions: &ModelHandle, + focus: bool, + startup_directory: Option, + ctx: &mut AppContext, +) -> (TuiSessionId, ViewHandle) { let (window_id, exit_summary, keyboard_enhancement_supported) = sessions.read(ctx, |sessions, _| sessions.surface_context()); + // The manager uses this internal model for unsupported-shell state; the + // TUI does not render a separate banner surface. let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( - std::env::current_dir().ok(), + startup_directory, HashMap::::from_iter(std::env::vars_os()), IsSharedSessionCreator::No, None, @@ -190,26 +215,17 @@ fn create_terminal_session_after_login( TerminalSurfaceResult { surface, post_wire: move |_manager: &mut LocalTtyTerminalManager, - surface: &ViewHandle, - ctx: &mut AppContext| { - if let Some(token) = resume_token { - surface.update(ctx, |view, ctx| { - view.restore_conversation( - TuiConversationRestoreTarget::Server(token), - TuiConversationRestoreOrigin::Startup, - ctx, - ); - }); - } - }, + _surface: &ViewHandle, + _ctx: &mut AppContext| {}, } }, ); - sessions.update(ctx, |sessions, ctx| { - sessions.add_session(manager.surface, manager.manager, true, ctx); + let surface = manager.surface.clone(); + let session_id = sessions.update(ctx, |sessions, ctx| { + sessions.add_session(manager.surface, manager.manager, focus, ctx) }); - root.update(ctx, |root, ctx| root.show_terminal(ctx)); + (session_id, surface) } #[cfg(test)] diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 0a52755227d..1fd7f187027 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -46,6 +46,8 @@ impl TuiSession { pub(crate) enum TuiSessionsEvent { /// A session was registered, possibly in the background. SessionAdded(TuiSessionId), + /// A session was removed from the container. + SessionRemoved(TuiSessionId), /// The focused session changed to this id. FocusChanged(TuiSessionId), } @@ -137,7 +139,23 @@ impl TuiSessions { self.keyboard_enhancement_supported, ) } - + /// Removes a session. When the focused session is removed, focus falls + /// back to the most recently added remaining session, if any. + pub(crate) fn remove_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) { + let before = self.sessions.len(); + self.sessions.retain(|session| session.id != id); + if self.sessions.len() == before { + return; + } + ctx.emit(TuiSessionsEvent::SessionRemoved(id)); + if self.focused_session_id == Some(id) { + self.focused_session_id = None; + if let Some(fallback) = self.sessions.last().map(|session| session.id) { + self.focus_session(fallback, ctx); + } + } + ctx.notify(); + } /// Focuses a registered session. Returns whether focus changed. pub(crate) fn focus_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) -> bool { if self.focused_session_id == Some(id) || self.session(id).is_none() { @@ -174,6 +192,10 @@ impl TuiSessions { pub(crate) fn is_empty(&self) -> bool { self.sessions.is_empty() } + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.sessions.len() + } /// Consumes the startup resume token. pub(crate) fn take_resume_token(&mut self) -> Option { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index bfa1a52487a..7e5b21ffc58 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,6 +1,7 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; use std::collections::HashMap; +use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -28,11 +29,12 @@ use warp::tui_export::{ GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, ParsedSlashCommandInput, PtyIntent, PtyIntentEvent, RepoDetectionSessionType, RepoDetectionSource, ServerConversationToken, ShellCommandExecutorEvent, SizeInfo, SizeUpdate, - SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, StaticCommand, - TerminalModel, TerminalSurface, TerminalSurfaceInit, TranscriptScope, TuiMcpAction, - TuiMcpManager, TuiSlashCommand, TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, - TuiZeroStateDataSource, UserTakeOverReason, COMMAND_REGISTRY, - LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, WAKEUP_THROTTLE_PERIOD, + SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, + StartAgentExecutorEvent, StartAgentRequest, StaticCommand, TerminalModel, TerminalSurface, + TerminalSurfaceInit, TranscriptScope, TuiMcpAction, TuiMcpManager, TuiSlashCommand, + TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, TuiZeroStateDataSource, + UserTakeOverReason, COMMAND_REGISTRY, LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, + WAKEUP_THROTTLE_PERIOD, }; use warp_core::features::FeatureFlag; use warp_core::settings::Setting; @@ -110,6 +112,13 @@ pub(crate) enum TuiTerminalSessionEvent { }, WriteUserInput(Cow<'static, [u8]>), Resize(SizeUpdate), + StartAgentConversation { + request: Box, + working_directory: Option, + }, + CleanupFailedChildLaunch { + conversation_id: AIConversationId, + }, } impl PtyIntentEvent for TuiTerminalSessionEvent { @@ -123,6 +132,7 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { }), Self::WriteUserInput(bytes) => Some(PtyIntent::WriteBytes(bytes.clone())), Self::Resize(size_update) => Some(PtyIntent::Resize(*size_update)), + Self::StartAgentConversation { .. } | Self::CleanupFailedChildLaunch { .. } => None, } } } @@ -630,6 +640,20 @@ impl TuiTerminalSessionView { ctx, ) }); + let start_agent_executor = action_model.as_ref(ctx).start_agent_executor(ctx); + ctx.subscribe_to_model(&start_agent_executor, |view, _, event, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + ctx.emit(TuiTerminalSessionEvent::StartAgentConversation { + request: request.clone(), + working_directory: view.current_working_directory(ctx).map(PathBuf::from), + }); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + ctx.emit(TuiTerminalSessionEvent::CleanupFailedChildLaunch { + conversation_id: *conversation_id, + }); + } + }); let ai_controller = ctx.add_model(|ctx| { BlocklistAIController::new( ai_input_model.clone(), @@ -1063,6 +1087,21 @@ impl TuiTerminalSessionView { } } + /// Starts the first request for a child conversation hosted by this + /// background session. + pub(crate) fn start_orchestrated_child( + &mut self, + task_id: warp::tui_export::AmbientAgentTaskId, + prompt: String, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.ai_controller.update(ctx, |controller, ctx| { + controller.set_ambient_agent_task_id(Some(task_id), ctx); + controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + }); + } + /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md new file mode 100644 index 00000000000..d58e564dc78 --- /dev/null +++ b/specs/code-1822-tui-local-children/TECH.md @@ -0,0 +1,110 @@ +# TECH: `TuiOrchestrationModel` + background local child agents +This change builds on the full-view `TuiSessions` container. Accepting a local +`run_agents` request in the TUI creates native Oz children in background terminal +sessions while the parent remains focused and receives orchestration traffic. +## Architecture +### Shared local Oz launch contract +The GUI and TUI share the frontend-neutral parts of native child launch through +`app/src/ai/blocklist/child_agent_launch.rs (16-93)`: +- `prepare_local_oz_child_launch` normalizes the child name and creates the server task row with + the prompt and parent run id. The returned `PreparedLocalOzChildLaunch` contains the task id and + normalized conversation name needed by the frontend-specific materializer. +- `inherit_child_agent_settings` copies the parent's execution profile and effective base model to + the child surface. +- `apply_child_agent_model_override` installs a non-empty run-wide model override after inheritance. +The GUI hidden-pane path uses the same helpers in +`app/src/pane_group/pane/terminal_pane.rs (1528-1705)`. The TUI therefore does +not export or directly compose `AIClient`, `AgentConfigSnapshot`, +`ServerApiProvider`, or `AIExecutionProfilesModel`. +### Session event bridge +Each `TuiTerminalSessionView` owns its `StartAgentExecutor` subscription and +converts executor events into semantic session events +(`crates/warp_tui/src/terminal_session_view.rs (111-136, 599-614)`): +- `CreateAgent` emits `StartAgentConversation` with the request and a snapshot of the parent's + current working directory. +- `CleanupFailedChildLaunch` emits the corresponding cleanup event. +`TuiOrchestrationModel::register` runs before the first session is created. It +subscribes to `TuiSessions` and, for every `SessionAdded`, subscribes to that +session view through `AppContext`; `SessionRemoved` tears down session-owned +streamer consumers (`crates/warp_tui/src/orchestration_model.rs (47-112)`). +Because every session, including a background child session, is registered in +`TuiSessions`, children are also wired to launch descendants. +### Native child launch +`TuiOrchestrationModel` separates task creation from TUI surface creation +(`crates/warp_tui/src/orchestration_model.rs (114-238)`): +1. `begin_local_oz_child_launch` starts shared server-task preparation. +2. `create_local_oz_child_session` creates an unfocused terminal session using the parent's + captured working directory. +3. The child inherits the parent's execution profile and effective base model, then receives the + requested run-wide model override. +4. `BlocklistAIHistoryModel::start_new_child_conversation` establishes lineage on the child + surface. The task id is stamped before `record_new_conversation_request_complete` resolves the + pending `StartAgentExecutor` slot. +5. The coordinator registers event consumers for the parent and child conversations. +6. `TuiTerminalSessionView::start_orchestrated_child` attaches the task id to the child controller + and sends the first prompt (`crates/warp_tui/src/terminal_session_view.rs (1034-1049)`). +`create_local_terminal_session` is the single session factory for both the +focused bootstrap session and background children. Its explicit startup-directory +parameter preserves the parent's current directory for child shells +(`crates/warp_tui/src/session.rs (152-217)`). +### Model selection +TUI `agents.model` remains the default model for ordinary TUI surfaces. +Explicit per-surface overrides are resolved first so a child `model_id` always +wins, including when it equals the execution profile default +(`app/src/ai/llms.rs (844-878, 1504-1526)`). +### Streamer and session ownership +The coordinator stores only frontend-specific runtime ownership +(`crates/warp_tui/src/orchestration_model.rs (31-39)`): +- `child_session_by_conversation` maps a child conversation to its background session. +- `event_consumers_by_session` records which conversation streams each live session consumes. +Conversation lineage remains canonical in `BlocklistAIHistoryModel`. Removing a +conversation also removes its id from `children_by_parent` +(`app/src/ai/blocklist/history_model.rs (2112-2182)`). +### Unsupported modes and failed launch cleanup +Local CLI-harness and remote requests resolve as explicit per-child failures +instead of waiting for the spawn timeout +(`crates/warp_tui/src/orchestration_model.rs (114-159, 239-296)`). +The failure path creates an errored child conversation on a synthetic surface +and echoes its id to `StartAgentExecutor`. The resulting cleanup event: +- deletes the child conversation and persisted state, +- removes it from the parent-child topology, +- removes any mapped background session, and +- unregisters consumers when the session is removed. +This leaves no dead child conversation, session, or streamer registration. +### Transcript rendering +`crates/warp_tui/src/agent_block.rs (775-888)`: +- suppresses the `WaitForEvents` tool-call row, matching the GUI, +- renders sender and subject for each `MessagesReceivedFromAgents` entry, and +- renders the number of received lifecycle events for `EventsFromAgents`. +Lifecycle output currently contains event ids rather than event details, so the +TUI intentionally renders a count rather than a sender/status transition. +## Exports +`app/src/tui_export.rs (52-75)` exposes the shared child-launch functions and +prepared result plus the `StartAgentExecutor` request/event/outcome types needed +by the TUI surface bridge. Server-client and execution-profile implementation +types remain behind the shared launch API. +## Non-goals +- Local CLI-harness children (Claude, Codex, OpenCode, Gemini). +- Remote/cloud child materialization. +- Navigation to or revealing background child sessions. +- Removing completed child sessions; successful children remain retained like GUI hidden panes. +- Rich message bodies and per-event lifecycle status rendering. +## Testing and validation +- `crates/warp_tui/src/orchestration_model_tests.rs (154-221)` verifies that local-harness and + remote requests resolve with explicit failures while leaving no child topology, extra session, + or event-consumer state. It also verifies that failed-launch cleanup preserves unrelated + retained sessions. +- `crates/warp_tui/src/agent_block_tests.rs (290-362)` renders orchestration messages and lifecycle + counts while asserting that `WaitForEvents` contributes no tool row. +- `app/src/ai/llms_tests.rs (936-959)` verifies that an explicit surface override precedes the TUI + file-backed default. +- `app/src/ai/blocklist/history_model_tests.rs (1872-1897)` verifies that removing a child + conversation cleans the parent index. +- `cargo check -p warp_tui` passes. +- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` passes. +- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` passes. +## Follow-ups +- Add local CLI-harness children by reusing the existing local-harness preparation path. +- Add a TUI-native remote child materializer. +- Add richer received-message and lifecycle-event rendering. +- Add child-session navigation and status UI on top of the retained `TuiSessions` entries.