From b7b0fa333d5caf09ff926abd11542df067a3844b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:37:00 -0400 Subject: [PATCH 01/10] Add TuiOrchestrationModel + local native child agents in background sessions The orchestration model subscribes to every session's StartAgentExecutor via TuiSessions registration, materializes native (Oz) local children into background sessions (create_agent_task -> shared create_local_terminal_session helper -> child conversation linkage -> prompt dispatch), and resolves CLI-harness/remote requests as clean per-child failures instead of spawn timeouts. Exports the StartAgent executor surface through tui_export and adds minimal transcript lines for inter-agent messages/lifecycle events (TODO(code-1822) for richer rendering). Co-Authored-By: Oz --- app/src/ai/blocklist/action_model.rs | 4 +- app/src/ai/blocklist/action_model/execute.rs | 3 +- app/src/ai/blocklist/mod.rs | 10 +- app/src/global_resource_handles.rs | 2 +- app/src/tui_export.rs | 18 +- app/src/tui_test_support.rs | 4 + crates/warp_tui/src/agent_block.rs | 27 +- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/orchestration_model.rs | 331 ++++++++++++++++++ .../warp_tui/src/orchestration_model_tests.rs | 178 ++++++++++ crates/warp_tui/src/root_view_tests.rs | 1 + crates/warp_tui/src/session.rs | 52 ++- crates/warp_tui/src/session_registry.rs | 20 +- crates/warp_tui/src/terminal_session_view.rs | 9 + specs/code-1822-tui-local-children/TECH.md | 148 ++++++++ 15 files changed, 774 insertions(+), 34 deletions(-) create mode 100644 crates/warp_tui/src/orchestration_model.rs create mode 100644 crates/warp_tui/src/orchestration_model_tests.rs create mode 100644 specs/code-1822-tui-local-children/TECH.md diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 328e5d06f2e..47be13f8357 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -35,8 +35,8 @@ pub use execute::{ PromptSuggestionExecutor, ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, - ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + ShellCommandExecutorEvent, StartAgentExecutor, 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/mod.rs b/app/src/ai/blocklist/mod.rs index 5210adf5e83..21c6699dd5f 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -46,8 +46,7 @@ pub use action_model::AIActionStatus; #[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, }; // Consumed by `tui_export` for the `warp_tui` frontend. #[cfg(feature = "tui")] @@ -58,6 +57,13 @@ pub use action_model::{ // Consumed by `tui_export` for the `warp_tui` frontend. #[cfg(feature = "tui")] 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(target_family = "wasm", 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}; 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/tui_export.rs b/app/src/tui_export.rs index d14b8289d53..cac528052fe 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -27,10 +27,10 @@ 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, 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, @@ -38,6 +38,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, @@ -62,6 +63,9 @@ 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, AskUserQuestionExecutor, @@ -69,7 +73,8 @@ pub use crate::ai::blocklist::{ BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, - ShellCommandExecutorEvent, + ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, + StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, @@ -78,6 +83,7 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; +pub use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, @@ -109,6 +115,8 @@ pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +pub use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot}; +pub use crate::server::server_api::ServerApiProvider; pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; 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 f2c4c1540f2..85103e5e38f 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -1027,7 +1027,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, @@ -1079,6 +1082,24 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, + // TODO: add full status rendering based on MOCs. + 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(_) @@ -1087,9 +1108,7 @@ impl TuiAIBlock { | AIAgentOutputMessageType::CommentsAddressed { .. } | AIAgentOutputMessageType::DebugOutput { .. } | AIAgentOutputMessageType::ArtifactCreated(_) - | AIAgentOutputMessageType::SkillInvoked(_) - | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } - | AIAgentOutputMessageType::EventsFromAgents { .. } => {} + | AIAgentOutputMessageType::SkillInvoked(_) => {} } } } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index f0ce4e4bdce..2215ea16aa7 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -35,6 +35,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..c0772582507 --- /dev/null +++ b/crates/warp_tui/src/orchestration_model.rs @@ -0,0 +1,331 @@ +//! [`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 warp::tui_export::{ + register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, + AIExecutionProfilesModel, AgentConfigSnapshot, AmbientAgentTaskId, BlocklistAIHistoryModel, + ConversationStatus, Harness, LLMId, LLMPreferences, RenderableAIError, ServerApiProvider, + StartAgentExecutionMode, StartAgentExecutorEvent, StartAgentRequest, +}; +use warpui::SingletonEntity; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ReadModel as _}; + +use crate::session::create_local_terminal_session; +use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; + +/// 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, + /// Sessions that have dispatched at least one child agent. + parent_sessions: HashSet, +} + +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); + ctx.add_singleton_model(|ctx| { + ctx.subscribe_to_model(&sessions, Self::handle_sessions_event); + Self { + child_session_by_conversation: HashMap::new(), + parent_sessions: HashSet::new(), + } + }) + } + + /// Wires a newly registered session's `StartAgentExecutor` to this model. + fn handle_sessions_event( + &mut self, + sessions: ModelHandle, + event: &TuiSessionsEvent, + ctx: &mut ModelContext, + ) { + let TuiSessionsEvent::SessionAdded(session_id) = event else { + return; + }; + let session_id = *session_id; + let Some(session_view) = ctx.read_model(&sessions, |sessions, _| { + sessions + .session(session_id) + .map(|session| session.view().clone()) + }) else { + return; + }; + let action_model = session_view.as_ref(ctx).ai_action_model().clone(); + let executor = ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)); + ctx.subscribe_to_model(&executor, move |me, _, event, ctx| { + me.handle_executor_event(session_id, event, ctx); + }); + } + + fn handle_executor_event( + &mut self, + parent_session_id: TuiSessionId, + event: &StartAgentExecutorEvent, + ctx: &mut ModelContext, + ) { + match event { + StartAgentExecutorEvent::CreateAgent(request) => { + self.dispatch_create_agent(parent_session_id, (**request).clone(), ctx); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + self.cleanup_failed_child(conversation_id, ctx); + } + } + } + + /// 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, + ctx: &mut ModelContext, + ) { + // Dispatching a child makes the parent an orchestrator: register it + // as a streamer consumer so its SSE stream (child lifecycle + inbox + // messages) opens. The GUI gets this from the agent view's + // `ActiveAgentViewsModel` bridge, which the TUI does not have. + register_agent_event_consumer( + request.parent_conversation_id, + parent_session_id.surface_id(), + ctx, + ); + match request.execution_mode.clone() { + StartAgentExecutionMode::Local { + harness_type: None, + model_id, + } => self.launch_native_child(parent_session_id, request, model_id, ctx), + StartAgentExecutionMode::Local { + harness_type: Some(harness_type), + .. + } => { + // TODO(code-1822): support local CLI-harness children by + // reusing the frontend-neutral + // `prepare_local_harness_child_launch` command builder. + 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. + fail_child_request( + &request, + "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), + ctx, + ); + } + } + } + + /// Native (Oz) local child: eagerly creates the server task row (which + /// activates messaging/lifecycle for the child), then materializes the + /// background session, mirroring the GUI's + /// `launch_local_no_harness_child`. + fn launch_native_child( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + model_id: Option, + ctx: &mut ModelContext, + ) { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let agent_name = Some(request.name.trim().to_owned()).filter(|name| !name.is_empty()); + let prompt = request.prompt.clone(); + let parent_run_id = request.parent_run_id.clone(); + ctx.spawn( + async move { + ai_client + .create_agent_task( + prompt, + None, + parent_run_id, + Some(AgentConfigSnapshot { + name: agent_name, + ..Default::default() + }), + ) + .await + }, + move |me, result, ctx| match result { + Ok(task_id) => me.materialize_native_child( + parent_session_id, + &request, + model_id.as_deref(), + task_id, + ctx, + ), + Err(error) => fail_child_request( + &request, + format!("Failed to create local child task: {error}"), + ctx, + ), + }, + ); + } + + /// Creates the background session, links the child conversation to the + /// parent, echoes it back to the executor, and dispatches the prompt. + fn materialize_native_child( + &mut self, + parent_session_id: TuiSessionId, + request: &StartAgentRequest, + model_id: Option<&str>, + task_id: AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + let sessions = TuiSessions::handle(ctx); + let window_id = ctx.read_model(&sessions, |sessions, ctx| { + sessions + .session(parent_session_id) + .expect("the dispatching parent session must remain registered") + .view() + .window_id(ctx) + }); + let (session_id, session_view) = + create_local_terminal_session(&sessions, window_id, false, ctx); + let child_surface_id = session_id.surface_id(); + + // Inherit the parent's execution profile and base model, then apply + // the run-wide model override — the TUI counterpart of the GUI's + // `propagate_parent_agent_settings` + `apply_child_model_id_override`. + let parent_surface_id = parent_session_id.surface_id(); + 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, |prefs, ctx| { + prefs.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); + }); + if let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) { + let llm_id: LLMId = model_id.into(); + LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { + prefs.update_preferred_agent_mode_llm(&llm_id, child_surface_id, ctx); + }); + } + + let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + child_surface_id, + request.name.clone(), + 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 + }); + + // Register the child as a streamer consumer so its own inbox stream + // (parent→child messages, wake events) opens. + register_agent_event_consumer(conversation_id, child_surface_id, ctx); + + let ai_controller = session_view.as_ref(ctx).ai_controller().clone(); + let prompt = request.prompt.clone(); + 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); + }); + + self.child_session_by_conversation + .insert(conversation_id, session_id); + self.parent_sessions.insert(parent_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 Some(session_id) = self.child_session_by_conversation.remove(conversation_id) else { + return; + }; + unregister_agent_event_consumer(*conversation_id, session_id.surface_id(), ctx); + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.remove_session(session_id, ctx); + }); + ctx.notify(); + } +} + +/// Resolves a child request as failed without materializing a session: +/// creates the child conversation on a synthetic surface, marks it errored, +/// then echoes it to the executor — which completes the pending slot with +/// the error message instead of hanging into the spawn timeout. +fn fail_child_request( + 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.clone(), + 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); + }); +} + +#[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..ab34bf595eb --- /dev/null +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -0,0 +1,178 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, StartAgentExecutionMode, StartAgentExecutor, + 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()); + 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) and returns its id +/// plus its surface's `StartAgentExecutor`. +fn add_dispatching_session( + app: &mut App, + fixture: &OrchestrationFixture, + focus: bool, +) -> (TuiSessionId, ModelHandle) { + 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.clone(), manager, focus, ctx) + }); + add_active_test_conversation(app, session_id.surface_id()); + let executor = app.read(|ctx| { + let action_model = session.as_ref(ctx).ai_action_model().clone(); + ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)) + }); + (session_id, 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, + fixture: &OrchestrationFixture, + session_id: TuiSessionId, + executor: &ModelHandle, + execution_mode: StartAgentExecutionMode, +) -> 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 _ = fixture; + 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, + ) + }); + 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})"); + } + } +} + +#[test] +fn local_harness_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + 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"); + }); +} + +#[test] +fn remote_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + 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"); + }); +} + +#[test] +fn sessions_registered_after_init_are_wired_for_orchestration() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + // First session exists before the dispatching one, mirroring a child + // session dispatching grandchildren later in an app's lifetime. + let _ = add_dispatching_session(&mut app, &fixture, true); + let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); + + let outcome = dispatch_and_recv( + &mut app, + &fixture, + late_session_id, + &late_executor, + StartAgentExecutionMode::Local { + harness_type: Some("codex".to_string()), + model_id: None, + }, + ); + // A resolved outcome proves the late session's executor is wired to + // the orchestration model (an unwired executor would never resolve). + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + }); +} diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index 195f2c942c5..53df6f9a339 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -30,6 +30,7 @@ fn root_projects_only_the_focused_retained_session_view() { root.update(&mut app, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { TuiSessionsEvent::SessionAdded(_) => {} + TuiSessionsEvent::SessionRemoved(_) => ctx.notify(), TuiSessionsEvent::FocusChanged(_) => ctx.notify(), }); }); diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 6ca994e4fb4..041b3f9c55c 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -21,11 +21,12 @@ use warp_errors::report_error; use warpui::SingletonEntity as _; use warpui_core::platform::{TerminationMode, WindowStyle}; use warpui_core::runtime::spawn_tui_driver; -use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle}; +use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle, WindowId}; +use crate::orchestration_model::TuiOrchestrationModel; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; -use crate::session_registry::{TuiSessions, TuiSessionsEvent}; +use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -127,9 +128,11 @@ fn init( root.update(ctx, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { TuiSessionsEvent::SessionAdded(_) => {} + TuiSessionsEvent::SessionRemoved(_) => ctx.notify(), TuiSessionsEvent::FocusChanged(_) => 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); @@ -155,7 +158,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, @@ -167,8 +170,30 @@ fn create_terminal_session_after_login( let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); let window_id = root.window_id(ctx); + let (_, surface) = create_local_terminal_session(sessions, window_id, true, 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, + window_id: WindowId, + focus: bool, + ctx: &mut AppContext, +) -> (TuiSessionId, ViewHandle) { let (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(), @@ -193,26 +218,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 18043d346ed..1c45d219e21 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), } @@ -132,7 +134,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() { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 56fdeaebad3..d3e10953602 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1078,6 +1078,15 @@ impl TuiTerminalSessionView { } } + /// The action model driving this session's agent tool execution. + pub(crate) fn ai_action_model(&self) -> &ModelHandle { + &self.ai_action_model + } + + /// The controller used to submit prompts into this session. + pub(crate) fn ai_controller(&self) -> &ModelHandle { + &self.ai_controller + } /// 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..f82eed06087 --- /dev/null +++ b/specs/code-1822-tui-local-children/TECH.md @@ -0,0 +1,148 @@ +# TECH: `TuiOrchestrationModel` + background local child agents + +Second PR of the two-PR stack, building on the full-view `TuiSessions` +container. Accepting a local `run_agents` request in the TUI spawns native +child agents in background sessions and makes the run observable in the +parent transcript. + +## Context + +The shared orchestration engine is frontend-neutral and already partially wired into the TUI: + +- The TUI's `TuiRunAgentsCardView` drives the shared `RunAgentsExecutor` accept path + ([crates/warp_tui/src/run_agents_card_view.rs:200-236 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/run_agents_card_view.rs#L200-L236)). +- `RunAgentsExecutor` fans out per child to `StartAgentExecutor::dispatch`, which emits + `StartAgentExecutorEvent::CreateAgent(Box)` and awaits materialization + ([app/src/ai/blocklist/action_model/execute/start_agent.rs:499,532-566 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L532-L566)). + Nothing in `crates/warp_tui` subscribes to that event today, and `StartAgentExecutor` is not yet + exported via `app/src/tui_export.rs`. +- In the GUI, materialization is `TerminalView::handle_start_agent_executor_event` + ([app/src/terminal/view.rs:7630-7650 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/terminal/view.rs#L7630-L7650)) + → `PaneGroup::create_hidden_child_agent_conversation` + ([app/src/pane_group/child_agent/mod.rs:130-179 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/child_agent/mod.rs#L130-L179)) — + pane-tree machinery the TUI cannot reuse. The frontend-neutral pieces it calls are reusable: + `BlocklistAIHistoryModel::start_new_child_conversation` + ([history_model.rs:508-544 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/history_model.rs#L508-L544)), + the `children_by_parent` lineage index, `ai_client.create_agent_task`, and + `StartAgentExecutor`'s self-completion off `BlocklistAIHistoryEvent`s + ([start_agent.rs:144-310 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L144-L310)). +- Messaging/lifecycle needs almost no TUI work: `OrchestrationEventStreamer`, + `OrchestrationEventService`, `LocalAgentTaskSyncModel`, and `MessageHydrator` are + frontend-neutral singletons already registered by the shared bootstrap the TUI binary runs + ([app/src/lib.rs:2051-2057 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/lib.rs#L2051-L2057)). + Transport is server-mediated (SSE + RPC) even between local agents in one process. The one + gap (found in live verification): the streamer only opens a conversation's SSE stream once a + *consumer* registers for it (`register_agent_event_consumer`), which the GUI drives from the + agent view's `ActiveAgentViewsModel` bridge — a surface the TUI lacks. Without it, children + sent messages but the parent never received them. `TuiOrchestrationModel` therefore registers + the parent as a consumer on dispatch and each child on materialization (and unregisters on + failed-launch cleanup). +- The TUI transcript currently drops orchestration traffic on the floor: + `MessagesReceivedFromAgents`/`EventsFromAgents` are explicit no-ops + ([crates/warp_tui/src/agent_block.rs:670-671 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/agent_block.rs#L670-L671)). + +Scope decisions (from the architecture discussion): native Oz children only; CLI-harness children +(Claude/Codex/OpenCode) are a follow-up, and remote children (whose GUI spawn path is coupled to +ambient-agent panes) also resolve as explicit per-child failures for now. Children stay invisible +— no navigation, no status bar; `TuiSessions` focus never moves off session 0 in this PR. +Following the GUI's hidden-pane prior art, each child session gets a full (backgrounded) +`TuiTerminalSessionView` retained by `TuiSessions`; the view doubles as the +terminal manager's PTY surface. + +## Proposed changes + +### Changed: `app/src/tui_export.rs` + +- Re-export `StartAgentExecutor`, `StartAgentExecutorEvent`, and `StartAgentRequest` (plus any + associated outcome types needed to fail a child), mirroring the existing + `RunAgentsExecutor` exports. + +### New: `crates/warp_tui/src/orchestration_model.rs` — `TuiOrchestrationModel` + +A `SingletonEntity` owning all TUI orchestration coordination: + +- Subscribes to `TuiSessions::SessionAdded` and, for each registered session, subscribes to that + session's `StartAgentExecutor`. Because all session creation flows through `TuiSessions` + (PR 2 invariant), every session — including children, enabling future nesting — is wired. +- On `StartAgentExecutorEvent::CreateAgent`, dispatches on the request mirroring the GUI's + per-mode dispatch: + - **Native (no harness)**: `ai_client.create_agent_task` (server task row → run_id, which is + what activates messaging/lifecycle for the child) → create a background session via the + shared `create_local_terminal_session` helper (PTY manager + full backgrounded view, + registered unfocused with `TuiSessions`) → inherit the parent's execution + profile/base model and apply the run-wide model override → + `BlocklistAIHistoryModel::start_new_child_conversation` + `set_task_id` → + `record_new_conversation_request_complete` (echoes the child `AIConversationId` so + `StartAgentExecutor` resolves its pending slot) → send the child's prompt via the child + session's `BlocklistAIController::send_agent_query_in_conversation`. + - **CLI-harness (Claude/Codex/OpenCode) and Remote**: resolve the pending slot with a clear + per-child failure outcome — a clean `failed` entry in the `launched` result rather than a + spawn-timeout hang. The failure path creates the child conversation on a synthetic surface, + marks it `Error`, and echoes it to the executor (which then also emits + `CleanupFailedChildLaunch`; the model tears down any mapped child session on that event). + TODO(code-1822): implement CLI-harness children by reusing the frontend-neutral + `prepare_local_harness_child_launch` + ([app/src/pane_group/pane/local_harness_launch.rs:158 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/pane/local_harness_launch.rs#L158)), + and remote children with a TUI-native spawn path. +- `crates/warp_tui/src/session.rs` extracts `create_local_terminal_session`, the single + session-materialization helper shared by the login bootstrap (focused) and child creation + (background). Callers provide the window from their existing view context, while the helper + obtains process-level exit-summary context from `TuiSessions`; `TuiOrchestrationModel` derives + the window from the requesting parent session rather than storing view-layer state. +- Tracking state is thin and session-dimensional only: + - `child_session_by_conversation: HashMap` + - `parent_sessions: HashSet` + Conversation lineage is always read from `BlocklistAIHistoryModel` (`children_by_parent`, + `parent_conversation_id`) — never mirrored here. This model adds only the conversation↔session + mapping that the shared layer doesn't know about, and is the future home/data source for session + navigation and child-status UI. +- `TuiTerminalSessionView` remains orchestration-ignorant; the coordinator + uses narrow accessors for its action model and controller. + +### Changed: `crates/warp_tui/src/agent_block.rs` — minimal orchestration transcript rendering + +- Replace the no-op arms for `MessagesReceivedFromAgents` and `EventsFromAgents` with simple + rendered lines: sender + subject for messages; sender + lifecycle transition for events. +- `TODO: add full status rendering based on MOCs.` marks the intentionally + minimal message/lifecycle lines. +- Suppress the `WaitForEvents` tool-call row ("Waiting for agent events…") entirely: the GUI + renders nothing for this action (its output match falls through to a no-op), so the TUI skips + emitting a transcript section for it rather than using the generic fallback label. + +### Non-goals + +- Remote and CLI-harness local children (explicit per-child failures, above), session + navigation/reveal, child cleanup UX on completion (children idle like GUI hidden panes; + lifecycle events surface state). + +## Testing and validation + +- Unit tests on `TuiOrchestrationModel` (per `rust-unit-tests`/`tui-testing` conventions): + - `CreateAgent` with a CLI harness or Remote mode resolves the executor's pending slot with the + per-child failure message and materializes no session. + - Sessions added after model init get their executors subscribed (the nesting invariant), + proven by a late-registered session's dispatch resolving. + - The native path's session materialization spawns a real PTY, so it is validated end-to-end + (below) rather than unit-tested against a mocked server client. +- Render-to-lines test: transcript renders message/lifecycle lines for + `MessagesReceivedFromAgents`/`EventsFromAgents` outputs. +- End-to-end manual validation per `tui-verify-change` (the key checkpoint that run_id + registration + SSE lifecycle work for TUI-spawned children): in `./script/run-tui`, prompt an + orchestration (`run_agents` with 2 local children) → accept → card shows launched; child + lifecycle (`in_progress`/`succeeded`) and completion messages appear in the parent transcript; + parent's tool result contains per-child `launched` entries with agent run ids. +- `./script/presubmit` before submit. + +## Parallelization + +Mostly none — the orchestration model, tui_export additions, and materializer are one coupled unit. +The transcript-rendering change (`agent_block.rs`) is independent and could be split to a parallel +local agent in a separate worktree, but it is ~50 LOC; not worth the coordination overhead. A +single agent implements this PR sequentially. + +## Follow-ups + +- CLI-harness local children (see TODO above). +- Richer transcript rendering for agent messages/events (see TODO above). +- Session navigation + child-status surface (next milestone; builds on `TuiSessions` focus and + `TuiOrchestrationModel` tracking). From 6969ea11eaab7196e7a9fba9a037ea9143cdd450 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 13:38:02 -0400 Subject: [PATCH 02/10] abstract out child agent launch fns Co-Authored-By: Oz --- app/src/ai/blocklist/child_agent_launch.rs | 93 +++++ app/src/ai/blocklist/history_model.rs | 4 + app/src/ai/blocklist/history_model_tests.rs | 26 ++ app/src/ai/blocklist/mod.rs | 10 +- app/src/ai/llms.rs | 38 +- app/src/ai/llms_tests.rs | 23 ++ app/src/pane_group/child_agent/mod.rs | 48 +-- app/src/pane_group/pane/terminal_pane.rs | 212 +++++------ app/src/tui_export.rs | 22 +- crates/warp_tui/src/agent_block.rs | 4 +- crates/warp_tui/src/agent_block_tests.rs | 71 +++- crates/warp_tui/src/orchestration_model.rs | 337 +++++++++--------- .../warp_tui/src/orchestration_model_tests.rs | 49 ++- crates/warp_tui/src/session.rs | 12 +- crates/warp_tui/src/session_registry.rs | 4 + crates/warp_tui/src/terminal_session_view.rs | 63 +++- specs/code-1822-tui-local-children/TECH.md | 254 ++++++------- 17 files changed, 749 insertions(+), 521 deletions(-) create mode 100644 app/src/ai/blocklist/child_agent_launch.rs 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 21c6699dd5f..b628519bc5f 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; @@ -59,7 +60,10 @@ pub use action_model::{ 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(target_family = "wasm", allow(unused_imports))] +#[cfg_attr( + any(target_family = "wasm", not(feature = "tui")), + allow(unused_imports) +)] pub use action_model::{ StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, @@ -68,6 +72,10 @@ pub use action_model::{ 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/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 10a2fd6a79b..fafc1f3ff71 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`. @@ -1640,12 +1627,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 @@ -1655,118 +1638,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 @@ -1846,7 +1818,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 cac528052fe..f7f0a4a6011 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -27,10 +27,11 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, RenderableAIError, 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, @@ -68,10 +69,12 @@ pub use crate::ai::blocklist::orchestration_event_streamer::{ }; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ - block_context_from_terminal_model, AIActionStatus, AskUserQuestionExecutor, - BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, - BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, - InputTypeAutoDetectionSource, PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, + apply_child_agent_model_override, block_context_from_terminal_model, + inherit_child_agent_settings, prepare_local_oz_child_launch, AIActionStatus, + AskUserQuestionExecutor, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, InputConfig, + InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, + PolicyConfigUpdate, PreparedLocalOzChildLaunch, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, @@ -83,7 +86,6 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -pub use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, @@ -115,8 +117,6 @@ pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -pub use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot}; -pub use crate::server::server_api::ServerApiProvider; pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 85103e5e38f..c64569e3f56 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -1082,7 +1082,8 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, - // TODO: add full status rendering based on MOCs. + + // TODO: add full status rendering for sub-agents. AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { for received in messages { sections.push(TuiAIBlockSection::RichText( @@ -1100,6 +1101,7 @@ impl TuiAIBlock { format!("Received {count} agent lifecycle event{plural}"), ))); } + // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 9d70175d679..ed33fe89e2f 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/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index c0772582507..5040032337a 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -13,18 +13,20 @@ //! 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, - AIExecutionProfilesModel, AgentConfigSnapshot, AmbientAgentTaskId, BlocklistAIHistoryModel, - ConversationStatus, Harness, LLMId, LLMPreferences, RenderableAIError, ServerApiProvider, - StartAgentExecutionMode, StartAgentExecutorEvent, StartAgentRequest, + BlocklistAIHistoryModel, ConversationStatus, Harness, PreparedLocalOzChildLaunch, + RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ReadModel as _}; 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 { @@ -32,8 +34,8 @@ pub(crate) struct TuiOrchestrationModel { /// only — conversation lineage is read from `BlocklistAIHistoryModel` /// (`children_by_parent` / `parent_conversation_id`), never mirrored. child_session_by_conversation: HashMap, - /// Sessions that have dispatched at least one child agent. - parent_sessions: HashSet, + /// Conversations whose event streams are consumed by each live session. + event_consumers_by_session: HashMap>, } impl Entity for TuiOrchestrationModel { @@ -48,53 +50,64 @@ impl TuiOrchestrationModel { /// run before any session is created. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { let sessions = TuiSessions::handle(ctx); - ctx.add_singleton_model(|ctx| { - ctx.subscribe_to_model(&sessions, Self::handle_sessions_event); - Self { - child_session_by_conversation: HashMap::new(), - parent_sessions: HashSet::new(), + 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); + }); + }); } - }) - } - - /// Wires a newly registered session's `StartAgentExecutor` to this model. - fn handle_sessions_event( - &mut self, - sessions: ModelHandle, - event: &TuiSessionsEvent, - ctx: &mut ModelContext, - ) { - let TuiSessionsEvent::SessionAdded(session_id) = event else { - return; - }; - let session_id = *session_id; - let Some(session_view) = ctx.read_model(&sessions, |sessions, _| { - sessions - .session(session_id) - .map(|session| session.view().clone()) - }) else { - return; - }; - let action_model = session_view.as_ref(ctx).ai_action_model().clone(); - let executor = ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)); - ctx.subscribe_to_model(&executor, move |me, _, event, ctx| { - me.handle_executor_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_executor_event( + fn handle_session_event( &mut self, parent_session_id: TuiSessionId, - event: &StartAgentExecutorEvent, + event: &TuiTerminalSessionEvent, ctx: &mut ModelContext, ) { match event { - StartAgentExecutorEvent::CreateAgent(request) => { - self.dispatch_create_agent(parent_session_id, (**request).clone(), ctx); + TuiTerminalSessionEvent::StartAgentConversation { + request, + working_directory, + } => { + self.dispatch_create_agent( + parent_session_id, + (**request).clone(), + working_directory.clone(), + ctx, + ); } - StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + TuiTerminalSessionEvent::CleanupFailedChildLaunch { conversation_id } => { self.cleanup_failed_child(conversation_id, ctx); } + TuiTerminalSessionEvent::ExecuteCommand(_) + | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::WriteAgentInput { .. } + | TuiTerminalSessionEvent::WriteUserInput(_) + | TuiTerminalSessionEvent::Resize(_) => {} } } @@ -105,30 +118,28 @@ impl TuiOrchestrationModel { &mut self, parent_session_id: TuiSessionId, request: StartAgentRequest, + working_directory: Option, ctx: &mut ModelContext, ) { - // Dispatching a child makes the parent an orchestrator: register it - // as a streamer consumer so its SSE stream (child lifecycle + inbox - // messages) opens. The GUI gets this from the agent view's - // `ActiveAgentViewsModel` bridge, which the TUI does not have. - register_agent_event_consumer( - request.parent_conversation_id, - parent_session_id.surface_id(), - ctx, - ); match request.execution_mode.clone() { StartAgentExecutionMode::Local { harness_type: None, model_id, - } => self.launch_native_child(parent_session_id, request, model_id, ctx), + } => self.begin_local_oz_child_launch( + parent_session_id, + request, + model_id, + working_directory, + ctx, + ), StartAgentExecutionMode::Local { harness_type: Some(harness_type), .. } => { - // TODO(code-1822): support local CLI-harness children by - // reusing the frontend-neutral - // `prepare_local_harness_child_launch` command builder. - fail_child_request( + // 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." @@ -139,7 +150,7 @@ impl TuiOrchestrationModel { StartAgentExecutionMode::Remote { .. } => { // TODO(code-1822): remote children need a TUI materializer; // the GUI's spawn path is coupled to ambient-agent panes. - fail_child_request( + self.fail_child_request( &request, "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), ctx, @@ -148,60 +159,48 @@ impl TuiOrchestrationModel { } } - /// Native (Oz) local child: eagerly creates the server task row (which - /// activates messaging/lifecycle for the child), then materializes the - /// background session, mirroring the GUI's - /// `launch_local_no_harness_child`. - fn launch_native_child( + /// 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 ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); - let agent_name = Some(request.name.trim().to_owned()).filter(|name| !name.is_empty()); - let prompt = request.prompt.clone(); - let parent_run_id = request.parent_run_id.clone(); - ctx.spawn( - async move { - ai_client - .create_agent_task( - prompt, - None, - parent_run_id, - Some(AgentConfigSnapshot { - name: agent_name, - ..Default::default() - }), - ) - .await - }, - move |me, result, ctx| match result { - Ok(task_id) => me.materialize_native_child( - parent_session_id, - &request, - model_id.as_deref(), - task_id, - ctx, - ), - Err(error) => fail_child_request( - &request, - format!("Failed to create local child task: {error}"), - ctx, - ), - }, + 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 session, links the child conversation to the - /// parent, echoes it back to the executor, and dispatches the prompt. - fn materialize_native_child( + /// 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>, - task_id: AmbientAgentTaskId, + working_directory: Option, + prepared: PreparedLocalOzChildLaunch, ctx: &mut ModelContext, ) { let sessions = TuiSessions::handle(ctx); @@ -212,38 +211,24 @@ impl TuiOrchestrationModel { .view() .window_id(ctx) }); - let (session_id, session_view) = - create_local_terminal_session(&sessions, window_id, false, ctx); + let (session_id, session_view) = create_local_terminal_session( + &sessions, + window_id, + false, + working_directory, + ctx, + ); let child_surface_id = session_id.surface_id(); + let task_id = prepared.task_id; - // Inherit the parent's execution profile and base model, then apply - // the run-wide model override — the TUI counterpart of the GUI's - // `propagate_parent_agent_settings` + `apply_child_model_id_override`. let parent_surface_id = parent_session_id.surface_id(); - 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, |prefs, ctx| { - prefs.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); - }); - if let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) { - let llm_id: LLMId = model_id.into(); - LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { - prefs.update_preferred_agent_mode_llm(&llm_id, child_surface_id, ctx); - }); - } + 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, - request.name.clone(), + prepared.conversation_name, request.parent_conversation_id, Some(Harness::Oz), ctx, @@ -258,20 +243,16 @@ impl TuiOrchestrationModel { conversation_id }); - // Register the child as a streamer consumer so its own inbox stream - // (parent→child messages, wake events) opens. - register_agent_event_consumer(conversation_id, child_surface_id, ctx); + self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); + self.register_event_consumer(session_id, conversation_id, ctx); - let ai_controller = session_view.as_ref(ctx).ai_controller().clone(); let prompt = request.prompt.clone(); - 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); + 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); - self.parent_sessions.insert(parent_session_id); ctx.notify(); } @@ -282,48 +263,72 @@ impl TuiOrchestrationModel { conversation_id: &AIConversationId, ctx: &mut ModelContext, ) { - let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) else { - return; - }; - unregister_agent_event_consumer(*conversation_id, session_id.surface_id(), ctx); - TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { - sessions.remove_session(session_id, ctx); + 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 materializing a session: -/// creates the child conversation on a synthetic surface, marks it errored, -/// then echoes it to the executor — which completes the pending slot with -/// the error message instead of hanging into the spawn timeout. -fn fail_child_request( - 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.clone(), - request.parent_conversation_id, - None, - ctx, - ); - history.update_conversation_status_with_error( - surface_id, - conversation_id, - ConversationStatus::Error, - Some(RenderableAIError::other(message, false)), - ctx, + /// 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 ); - history.record_new_conversation_request_complete(request.id, conversation_id, ctx); - }); + 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)] diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index ab34bf595eb..d417ae11088 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,6 +1,6 @@ use warp::tui_export::{ - register_tui_session_view_test_singletons, StartAgentExecutionMode, StartAgentExecutor, - StartAgentOutcome, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + StartAgentExecutionMode, StartAgentExecutor, StartAgentOutcome, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; @@ -55,10 +55,7 @@ fn add_dispatching_session( sessions.add_session(session.clone(), manager, focus, ctx) }); add_active_test_conversation(app, session_id.surface_id()); - let executor = app.read(|ctx| { - let action_model = session.as_ref(ctx).ai_action_model().clone(); - ctx.read_model(&action_model, |model, app| model.start_agent_executor(app)) - }); + let executor = app.read(|ctx| session.as_ref(ctx).start_agent_executor_for_test(ctx)); (session_id, executor) } @@ -71,7 +68,7 @@ fn dispatch_and_recv( session_id: TuiSessionId, executor: &ModelHandle, execution_mode: StartAgentExecutionMode, -) -> StartAgentOutcome { +) -> (AIConversationId, StartAgentOutcome) { let parent_conversation_id = app.read(|ctx| { warp::tui_export::BlocklistAIHistoryModel::as_ref(ctx) .active_conversation(session_id.surface_id()) @@ -90,9 +87,12 @@ fn dispatch_and_recv( ctx, ) }); - receiver - .try_recv() - .expect("unsupported-mode dispatches resolve before the update returns") + ( + parent_conversation_id, + receiver + .try_recv() + .expect("unsupported-mode dispatches resolve before the update returns"), + ) } fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { @@ -106,13 +106,33 @@ fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { } } +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, executor) = add_dispatching_session(&mut app, &fixture, true); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, session_id, @@ -123,6 +143,7 @@ fn local_harness_children_fail_cleanly() { }, ); assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); }); } @@ -132,7 +153,7 @@ fn remote_children_fail_cleanly() { let fixture = orchestration_fixture(&mut app); let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, session_id, @@ -149,6 +170,7 @@ fn remote_children_fail_cleanly() { }, ); assert_error_containing(outcome, "Cloud child agents aren't supported"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); }); } @@ -161,7 +183,7 @@ fn sessions_registered_after_init_are_wired_for_orchestration() { let _ = add_dispatching_session(&mut app, &fixture, true); let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); - let outcome = dispatch_and_recv( + let (parent_conversation_id, outcome) = dispatch_and_recv( &mut app, &fixture, late_session_id, @@ -174,5 +196,6 @@ fn sessions_registered_after_init_are_wired_for_orchestration() { // A resolved outcome proves the late session's executor is wired to // the orchestration model (an unwired executor would never resolve). 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 041b3f9c55c..b359465b3d6 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::ffi::OsString; +use std::path::PathBuf; use anyhow::{Context, Result}; use clap::error::ErrorKind; @@ -170,7 +171,13 @@ fn create_terminal_session_after_login( let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); let window_id = root.window_id(ctx); - let (_, surface) = create_local_terminal_session(sessions, window_id, true, ctx); + let (_, surface) = create_local_terminal_session( + sessions, + window_id, + true, + std::env::current_dir().ok(), + ctx, + ); if let Some(token) = resume_token { surface.update(ctx, |view, ctx| { view.restore_conversation( @@ -188,6 +195,7 @@ pub(crate) fn create_local_terminal_session( sessions: &ModelHandle, window_id: WindowId, focus: bool, + startup_directory: Option, ctx: &mut AppContext, ) -> (TuiSessionId, ViewHandle) { let (exit_summary, keyboard_enhancement_supported) = @@ -196,7 +204,7 @@ pub(crate) fn create_local_terminal_session( // 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, diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 1c45d219e21..c3bbbab9192 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -187,6 +187,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 d3e10953602..7c5a2d84f74 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; @@ -11,6 +12,8 @@ use instant::Instant; use parking_lot::FairMutex; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::settings::{AISettings, AISettingsChangedEvent}; +#[cfg(test)] +use warp::tui_export::StartAgentExecutor; use warp::tui_export::{ block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, prepare_conversation_block_restoration, @@ -29,11 +32,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; @@ -111,6 +115,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 { @@ -124,6 +135,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, } } } @@ -402,6 +414,13 @@ impl TuiTerminalSessionView { }); } + #[cfg(test)] + pub(crate) fn start_agent_executor_for_test( + &self, + ctx: &AppContext, + ) -> ModelHandle { + self.ai_action_model.as_ref(ctx).start_agent_executor(ctx) + } fn detach_cli_subagent_view( &mut self, block_id: &BlockId, @@ -631,6 +650,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(), @@ -1078,15 +1111,21 @@ impl TuiTerminalSessionView { } } - /// The action model driving this session's agent tool execution. - pub(crate) fn ai_action_model(&self) -> &ModelHandle { - &self.ai_action_model + /// 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 controller used to submit prompts into this session. - pub(crate) fn ai_controller(&self) -> &ModelHandle { - &self.ai_controller - } /// 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 index f82eed06087..f279290f195 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -1,148 +1,112 @@ # TECH: `TuiOrchestrationModel` + background local child agents - -Second PR of the two-PR stack, building on the full-view `TuiSessions` -container. Accepting a local `run_agents` request in the TUI spawns native -child agents in background sessions and makes the run observable in the -parent transcript. - -## Context - -The shared orchestration engine is frontend-neutral and already partially wired into the TUI: - -- The TUI's `TuiRunAgentsCardView` drives the shared `RunAgentsExecutor` accept path - ([crates/warp_tui/src/run_agents_card_view.rs:200-236 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/run_agents_card_view.rs#L200-L236)). -- `RunAgentsExecutor` fans out per child to `StartAgentExecutor::dispatch`, which emits - `StartAgentExecutorEvent::CreateAgent(Box)` and awaits materialization - ([app/src/ai/blocklist/action_model/execute/start_agent.rs:499,532-566 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L532-L566)). - Nothing in `crates/warp_tui` subscribes to that event today, and `StartAgentExecutor` is not yet - exported via `app/src/tui_export.rs`. -- In the GUI, materialization is `TerminalView::handle_start_agent_executor_event` - ([app/src/terminal/view.rs:7630-7650 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/terminal/view.rs#L7630-L7650)) - → `PaneGroup::create_hidden_child_agent_conversation` - ([app/src/pane_group/child_agent/mod.rs:130-179 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/child_agent/mod.rs#L130-L179)) — - pane-tree machinery the TUI cannot reuse. The frontend-neutral pieces it calls are reusable: - `BlocklistAIHistoryModel::start_new_child_conversation` - ([history_model.rs:508-544 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/history_model.rs#L508-L544)), - the `children_by_parent` lineage index, `ai_client.create_agent_task`, and - `StartAgentExecutor`'s self-completion off `BlocklistAIHistoryEvent`s - ([start_agent.rs:144-310 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/ai/blocklist/action_model/execute/start_agent.rs#L144-L310)). -- Messaging/lifecycle needs almost no TUI work: `OrchestrationEventStreamer`, - `OrchestrationEventService`, `LocalAgentTaskSyncModel`, and `MessageHydrator` are - frontend-neutral singletons already registered by the shared bootstrap the TUI binary runs - ([app/src/lib.rs:2051-2057 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/lib.rs#L2051-L2057)). - Transport is server-mediated (SSE + RPC) even between local agents in one process. The one - gap (found in live verification): the streamer only opens a conversation's SSE stream once a - *consumer* registers for it (`register_agent_event_consumer`), which the GUI drives from the - agent view's `ActiveAgentViewsModel` bridge — a surface the TUI lacks. Without it, children - sent messages but the parent never received them. `TuiOrchestrationModel` therefore registers - the parent as a consumer on dispatch and each child on materialization (and unregisters on - failed-launch cleanup). -- The TUI transcript currently drops orchestration traffic on the floor: - `MessagesReceivedFromAgents`/`EventsFromAgents` are explicit no-ops - ([crates/warp_tui/src/agent_block.rs:670-671 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/crates/warp_tui/src/agent_block.rs#L670-L671)). - -Scope decisions (from the architecture discussion): native Oz children only; CLI-harness children -(Claude/Codex/OpenCode) are a follow-up, and remote children (whose GUI spawn path is coupled to -ambient-agent panes) also resolve as explicit per-child failures for now. Children stay invisible -— no navigation, no status bar; `TuiSessions` focus never moves off session 0 in this PR. -Following the GUI's hidden-pane prior art, each child session gets a full (backgrounded) -`TuiTerminalSessionView` retained by `TuiSessions`; the view doubles as the -terminal manager's PTY surface. - -## Proposed changes - -### Changed: `app/src/tui_export.rs` - -- Re-export `StartAgentExecutor`, `StartAgentExecutorEvent`, and `StartAgentRequest` (plus any - associated outcome types needed to fail a child), mirroring the existing - `RunAgentsExecutor` exports. - -### New: `crates/warp_tui/src/orchestration_model.rs` — `TuiOrchestrationModel` - -A `SingletonEntity` owning all TUI orchestration coordination: - -- Subscribes to `TuiSessions::SessionAdded` and, for each registered session, subscribes to that - session's `StartAgentExecutor`. Because all session creation flows through `TuiSessions` - (PR 2 invariant), every session — including children, enabling future nesting — is wired. -- On `StartAgentExecutorEvent::CreateAgent`, dispatches on the request mirroring the GUI's - per-mode dispatch: - - **Native (no harness)**: `ai_client.create_agent_task` (server task row → run_id, which is - what activates messaging/lifecycle for the child) → create a background session via the - shared `create_local_terminal_session` helper (PTY manager + full backgrounded view, - registered unfocused with `TuiSessions`) → inherit the parent's execution - profile/base model and apply the run-wide model override → - `BlocklistAIHistoryModel::start_new_child_conversation` + `set_task_id` → - `record_new_conversation_request_complete` (echoes the child `AIConversationId` so - `StartAgentExecutor` resolves its pending slot) → send the child's prompt via the child - session's `BlocklistAIController::send_agent_query_in_conversation`. - - **CLI-harness (Claude/Codex/OpenCode) and Remote**: resolve the pending slot with a clear - per-child failure outcome — a clean `failed` entry in the `launched` result rather than a - spawn-timeout hang. The failure path creates the child conversation on a synthetic surface, - marks it `Error`, and echoes it to the executor (which then also emits - `CleanupFailedChildLaunch`; the model tears down any mapped child session on that event). - TODO(code-1822): implement CLI-harness children by reusing the frontend-neutral - `prepare_local_harness_child_launch` - ([app/src/pane_group/pane/local_harness_launch.rs:158 @ 41d0b004](https://github.com/warpdotdev/warp/blob/41d0b004219adff1624cbaf942f52b2d64244d75/app/src/pane_group/pane/local_harness_launch.rs#L158)), - and remote children with a TUI-native spawn path. -- `crates/warp_tui/src/session.rs` extracts `create_local_terminal_session`, the single - session-materialization helper shared by the login bootstrap (focused) and child creation - (background). Callers provide the window from their existing view context, while the helper - obtains process-level exit-summary context from `TuiSessions`; `TuiOrchestrationModel` derives - the window from the requesting parent session rather than storing view-layer state. -- Tracking state is thin and session-dimensional only: - - `child_session_by_conversation: HashMap` - - `parent_sessions: HashSet` - Conversation lineage is always read from `BlocklistAIHistoryModel` (`children_by_parent`, - `parent_conversation_id`) — never mirrored here. This model adds only the conversation↔session - mapping that the shared layer doesn't know about, and is the future home/data source for session - navigation and child-status UI. -- `TuiTerminalSessionView` remains orchestration-ignorant; the coordinator - uses narrow accessors for its action model and controller. - -### Changed: `crates/warp_tui/src/agent_block.rs` — minimal orchestration transcript rendering - -- Replace the no-op arms for `MessagesReceivedFromAgents` and `EventsFromAgents` with simple - rendered lines: sender + subject for messages; sender + lifecycle transition for events. -- `TODO: add full status rendering based on MOCs.` marks the intentionally - minimal message/lifecycle lines. -- Suppress the `WaitForEvents` tool-call row ("Waiting for agent events…") entirely: the GUI - renders nothing for this action (its output match falls through to a no-op), so the TUI skips - emitting a transcript section for it rather than using the generic fallback label. - -### Non-goals - -- Remote and CLI-harness local children (explicit per-child failures, above), session - navigation/reveal, child cleanup UX on completion (children idle like GUI hidden panes; - lifecycle events surface state). - +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. Callers pass the window from +their existing view context rather than storing it in `TuiSessions`; child +orchestration derives it from the requesting parent session. The factory's +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 - -- Unit tests on `TuiOrchestrationModel` (per `rust-unit-tests`/`tui-testing` conventions): - - `CreateAgent` with a CLI harness or Remote mode resolves the executor's pending slot with the - per-child failure message and materializes no session. - - Sessions added after model init get their executors subscribed (the nesting invariant), - proven by a late-registered session's dispatch resolving. - - The native path's session materialization spawns a real PTY, so it is validated end-to-end - (below) rather than unit-tested against a mocked server client. -- Render-to-lines test: transcript renders message/lifecycle lines for - `MessagesReceivedFromAgents`/`EventsFromAgents` outputs. -- End-to-end manual validation per `tui-verify-change` (the key checkpoint that run_id - registration + SSE lifecycle work for TUI-spawned children): in `./script/run-tui`, prompt an - orchestration (`run_agents` with 2 local children) → accept → card shows launched; child - lifecycle (`in_progress`/`succeeded`) and completion messages appear in the parent transcript; - parent's tool result contains per-child `launched` entries with agent run ids. -- `./script/presubmit` before submit. - -## Parallelization - -Mostly none — the orchestration model, tui_export additions, and materializer are one coupled unit. -The transcript-rendering change (`agent_block.rs`) is independent and could be split to a parallel -local agent in a separate worktree, but it is ~50 LOC; not worth the coordination overhead. A -single agent implements this PR sequentially. - +- `crates/warp_tui/src/orchestration_model_tests.rs (121-199)` verifies that local-harness and + remote requests resolve with explicit failures while leaving no child topology, extra session, + or event-consumer state. It also proves that sessions added after coordinator initialization are + wired for orchestration. +- `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 - -- CLI-harness local children (see TODO above). -- Richer transcript rendering for agent messages/events (see TODO above). -- Session navigation + child-status surface (next milestone; builds on `TuiSessions` focus and - `TuiOrchestrationModel` tracking). +- 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. From 18ba5d803e0e1aaf5ccc6f8fb4552841613633b1 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 14:01:58 -0400 Subject: [PATCH 03/10] remove unnecessary test helper Co-Authored-By: Oz --- .../warp_tui/src/orchestration_model_tests.rs | 64 ++++++++++++------- crates/warp_tui/src/terminal_session_view.rs | 9 --- specs/code-1822-tui-local-children/TECH.md | 6 +- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index d417ae11088..09d617ba081 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,6 +1,6 @@ use warp::tui_export::{ register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, - StartAgentExecutionMode, StartAgentExecutor, StartAgentOutcome, + StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; @@ -43,20 +43,46 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } -/// Registers a session (with a live active conversation) and returns its id -/// plus its surface's `StartAgentExecutor`. +/// Registers a session with a live active conversation. fn add_dispatching_session( app: &mut App, fixture: &OrchestrationFixture, focus: bool, -) -> (TuiSessionId, ModelHandle) { +) -> 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.clone(), manager, focus, ctx) + sessions.add_session(session, manager, focus, ctx) }); add_active_test_conversation(app, session_id.surface_id()); - let executor = app.read(|ctx| session.as_ref(ctx).start_agent_executor_for_test(ctx)); - (session_id, executor) + 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 @@ -64,7 +90,6 @@ fn add_dispatching_session( /// unsupported modes synchronously within the same effect flush). fn dispatch_and_recv( app: &mut App, - fixture: &OrchestrationFixture, session_id: TuiSessionId, executor: &ModelHandle, execution_mode: StartAgentExecutionMode, @@ -75,7 +100,6 @@ fn dispatch_and_recv( .expect("fixture registered an active conversation") .id() }); - let _ = fixture; let receiver = app.update_model(executor, |executor, ctx| { executor.dispatch( "researcher".to_string(), @@ -130,11 +154,11 @@ fn assert_failed_launch_cleaned_up( fn local_harness_children_fail_cleanly() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + 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, - &fixture, session_id, &executor, StartAgentExecutionMode::Local { @@ -151,11 +175,11 @@ fn local_harness_children_fail_cleanly() { fn remote_children_fail_cleanly() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - let (session_id, executor) = add_dispatching_session(&mut app, &fixture, true); + 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, - &fixture, session_id, &executor, StartAgentExecutionMode::Remote { @@ -175,26 +199,22 @@ fn remote_children_fail_cleanly() { } #[test] -fn sessions_registered_after_init_are_wired_for_orchestration() { +fn failed_launch_cleanup_preserves_other_sessions() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - // First session exists before the dispatching one, mirroring a child - // session dispatching grandchildren later in an app's lifetime. let _ = add_dispatching_session(&mut app, &fixture, true); - let (late_session_id, late_executor) = add_dispatching_session(&mut app, &fixture, false); + 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, - &fixture, - late_session_id, - &late_executor, + background_session_id, + &executor, StartAgentExecutionMode::Local { harness_type: Some("codex".to_string()), model_id: None, }, ); - // A resolved outcome proves the late session's executor is wired to - // the orchestration model (an unwired executor would never resolve). 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/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 7c5a2d84f74..00fa81cc2bb 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -12,8 +12,6 @@ use instant::Instant; use parking_lot::FairMutex; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::settings::{AISettings, AISettingsChangedEvent}; -#[cfg(test)] -use warp::tui_export::StartAgentExecutor; use warp::tui_export::{ block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, prepare_conversation_block_restoration, @@ -414,13 +412,6 @@ impl TuiTerminalSessionView { }); } - #[cfg(test)] - pub(crate) fn start_agent_executor_for_test( - &self, - ctx: &AppContext, - ) -> ModelHandle { - self.ai_action_model.as_ref(ctx).start_agent_executor(ctx) - } fn detach_cli_subagent_view( &mut self, block_id: &BlockId, diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index f279290f195..0b280d751b7 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -92,10 +92,10 @@ types remain behind the shared launch API. - 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 (121-199)` verifies that local-harness and +- `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 proves that sessions added after coordinator initialization are - wired for orchestration. + 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 From d94b79f653c6cfc902ef80e76d40aee0635a4f06 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 12:28:10 -0400 Subject: [PATCH 04/10] switch owning relationship between sessions and orchestration model --- crates/warp_tui/src/orchestration_model.rs | 176 +++++++----------- .../warp_tui/src/orchestration_model_tests.rs | 8 +- crates/warp_tui/src/root_view_tests.rs | 9 +- crates/warp_tui/src/session.rs | 18 +- crates/warp_tui/src/session_registry.rs | 149 ++++++++++++--- crates/warp_tui/src/session_registry_tests.rs | 20 +- specs/code-1822-tui-local-children/TECH.md | 43 +++-- 7 files changed, 228 insertions(+), 195 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 5040032337a..21ad7f0cb49 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -3,11 +3,11 @@ //! 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`. +//! panes; in the TUI, [`crate::session_registry::TuiSessions`] owns +//! materialization. This singleton prepares native Oz children, requests +//! background session lifecycle changes, 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. @@ -18,15 +18,14 @@ 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, + BlocklistAIHistoryModel, ConversationStatus, Harness, RenderableAIError, + StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; -use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ReadModel as _}; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; -use crate::session::create_local_terminal_session; -use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; -use crate::terminal_session_view::TuiTerminalSessionEvent; +use crate::session_registry::TuiSessionId; +use crate::terminal_session_view::TuiTerminalSessionView; /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { @@ -37,84 +36,46 @@ pub(crate) struct TuiOrchestrationModel { /// Conversations whose event streams are consumed by each live session. event_consumers_by_session: HashMap>, } +pub(crate) enum TuiOrchestrationEvent { + CreateLocalOzChildSession { + parent_session_id: TuiSessionId, + request: Box, + model_id: Option, + working_directory: Option, + task_id: warp::tui_export::AmbientAgentTaskId, + conversation_name: String, + }, + RemoveChildSession(TuiSessionId), +} +pub(crate) struct MaterializedLocalOzChildSession { + pub(crate) parent_session_id: TuiSessionId, + pub(crate) session_id: TuiSessionId, + pub(crate) session_view: ViewHandle, + pub(crate) request: StartAgentRequest, + pub(crate) model_id: Option, + pub(crate) task_id: warp::tui_export::AmbientAgentTaskId, + pub(crate) conversation_name: String, +} impl Entity for TuiOrchestrationModel { - type Event = (); + type Event = TuiOrchestrationEvent; } 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. + /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { - let sessions = TuiSessions::handle(ctx); - let model = ctx.add_singleton_model(|_| Self { + 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( + pub(crate) fn dispatch_create_agent( &mut self, parent_session_id: TuiSessionId, request: StartAgentRequest, @@ -176,14 +137,14 @@ impl TuiOrchestrationModel { ctx, ); ctx.spawn(launch, move |me, result, ctx| match result { - Ok(prepared) => me.create_local_oz_child_session( + Ok(prepared) => ctx.emit(TuiOrchestrationEvent::CreateLocalOzChildSession { parent_session_id, - &request, - model_id.as_deref(), + request: Box::new(request), + model_id, working_directory, - prepared, - ctx, - ), + task_id: prepared.task_id, + conversation_name: prepared.conversation_name, + }), Err(error) => me.fail_child_request( &request, format!("Failed to create local child task: {error}"), @@ -192,43 +153,32 @@ impl TuiOrchestrationModel { }); } - /// 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( + /// Registers a materialized background session and child conversation for + /// a prepared task, then sends the child's first prompt. + pub(crate) fn register_local_oz_child_session( &mut self, - parent_session_id: TuiSessionId, - request: &StartAgentRequest, - model_id: Option<&str>, - working_directory: Option, - prepared: PreparedLocalOzChildLaunch, + child: MaterializedLocalOzChildSession, ctx: &mut ModelContext, ) { - let sessions = TuiSessions::handle(ctx); - let window_id = ctx.read_model(&sessions, |sessions, ctx| { - sessions - .session(parent_session_id) - .expect("the dispatching parent session must remain registered") - .view() - .window_id(ctx) - }); - let (session_id, session_view) = create_local_terminal_session( - &sessions, - window_id, - false, - working_directory, - ctx, - ); + let MaterializedLocalOzChildSession { + parent_session_id, + session_id, + session_view, + request, + model_id, + task_id, + conversation_name, + } = child; 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); + apply_child_agent_model_override(child_surface_id, model_id.as_deref(), 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, + conversation_name, request.parent_conversation_id, Some(Harness::Oz), ctx, @@ -246,7 +196,7 @@ impl TuiOrchestrationModel { 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(); + let prompt = request.prompt; session_view.update(ctx, |view, ctx| { view.start_orchestrated_child(task_id, prompt, conversation_id, ctx); }); @@ -258,7 +208,7 @@ impl TuiOrchestrationModel { /// Tears down the background session of a child that failed at the /// launch stage (the executor's `CleanupFailedChildLaunch`). - fn cleanup_failed_child( + pub(crate) fn cleanup_failed_child( &mut self, conversation_id: &AIConversationId, ctx: &mut ModelContext, @@ -269,9 +219,7 @@ impl TuiOrchestrationModel { 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.emit(TuiOrchestrationEvent::RemoveChildSession(session_id)); } ctx.notify(); } @@ -320,7 +268,11 @@ impl TuiOrchestrationModel { .insert(conversation_id); } - fn handle_session_removed(&mut self, session_id: TuiSessionId, ctx: &mut ModelContext) { + pub(crate) 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); diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 09d617ba081..20a6ba93148 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -36,7 +36,8 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { root.update(app, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); }); - app.update(TuiOrchestrationModel::register); + let orchestration = app.update(TuiOrchestrationModel::register); + app.update(|ctx| TuiSessions::wire_orchestration(&sessions, &orchestration, ctx)); OrchestrationFixture { sessions, window_id, @@ -50,8 +51,8 @@ fn add_dispatching_session( 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) + let session_id = app.update(|ctx| { + TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx) }); add_active_test_conversation(app, session_id.surface_id()); session_id @@ -191,6 +192,7 @@ fn remote_children_fail_cleanly() { harness_type: "oz".to_string(), title: "Researcher".to_string(), auth_secret_name: None, + agent_identity_uid: None, }, ); assert_error_containing(outcome, "Cloud child agents aren't supported"); diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs index 53df6f9a339..b980c481229 100644 --- a/crates/warp_tui/src/root_view_tests.rs +++ b/crates/warp_tui/src/root_view_tests.rs @@ -29,7 +29,6 @@ fn root_projects_only_the_focused_retained_session_view() { let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); root.update(&mut app, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { - TuiSessionsEvent::SessionAdded(_) => {} TuiSessionsEvent::SessionRemoved(_) => ctx.notify(), TuiSessionsEvent::FocusChanged(_) => ctx.notify(), }); @@ -40,8 +39,8 @@ fn root_projects_only_the_focused_retained_session_view() { let (first, first_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let first_id = app.update_model(&sessions, |sessions, ctx| { - sessions.add_session(first, first_manager, true, ctx) + let first_id = app.update(|ctx| { + TuiSessions::register_session(&sessions, first, first_manager, true, ctx) }); app.read(|ctx| { assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); @@ -55,8 +54,8 @@ fn root_projects_only_the_focused_retained_session_view() { let (second, second_manager) = add_test_terminal_session(&mut app, window_id); let second_view_id = second.id(); - let second_id = app.update_model(&sessions, |sessions, ctx| { - sessions.add_session(second, second_manager, false, ctx) + let second_id = app.update(|ctx| { + TuiSessions::register_session(&sessions, second, second_manager, false, ctx) }); app.read(|ctx| { assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index b359465b3d6..1c40134e344 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -128,12 +128,12 @@ fn init( ctx.add_singleton_model(|_| TuiSessions::new(driver, exit_summary, resume_token)); root.update(ctx, |_, ctx| { ctx.subscribe_to_model(&sessions, |_, _, event, ctx| match event { - TuiSessionsEvent::SessionAdded(_) => {} TuiSessionsEvent::SessionRemoved(_) => ctx.notify(), TuiSessionsEvent::FocusChanged(_) => ctx.notify(), }); }); - TuiOrchestrationModel::register(ctx); + let orchestration = TuiOrchestrationModel::register(ctx); + TuiSessions::wire_orchestration(&sessions, &orchestration, 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); @@ -171,13 +171,8 @@ fn create_terminal_session_after_login( let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); let window_id = root.window_id(ctx); - let (_, surface) = create_local_terminal_session( - sessions, - window_id, - true, - std::env::current_dir().ok(), - ctx, - ); + let (_, surface) = + create_local_terminal_session(sessions, window_id, true, std::env::current_dir().ok(), ctx); if let Some(token) = resume_token { surface.update(ctx, |view, ctx| { view.restore_conversation( @@ -233,9 +228,8 @@ pub(crate) fn create_local_terminal_session( ); let surface = manager.surface.clone(); - let session_id = sessions.update(ctx, |sessions, ctx| { - sessions.add_session(manager.surface, manager.manager, focus, ctx) - }); + let session_id = + TuiSessions::register_session(sessions, manager.surface, manager.manager, focus, ctx); (session_id, surface) } diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index c3bbbab9192..bfbccd7d531 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -7,10 +7,14 @@ use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; -use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; +use crate::orchestration_model::{ + MaterializedLocalOzChildSession, TuiOrchestrationEvent, TuiOrchestrationModel, +}; use crate::resume::TuiExitSummaryHandle; -use crate::terminal_session_view::TuiTerminalSessionView; +use crate::session::create_local_terminal_session; +use crate::terminal_session_view::{TuiTerminalSessionEvent, TuiTerminalSessionView}; /// Identifies a TUI terminal session. /// @@ -44,8 +48,6 @@ impl TuiSession { /// Events emitted as the session set or focus changes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] 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. @@ -71,6 +73,114 @@ impl Entity for TuiSessions { impl SingletonEntity for TuiSessions {} impl TuiSessions { + /// Wires a session view to orchestration before registering it. + pub(crate) fn register_session( + sessions: &ModelHandle, + view: ViewHandle, + manager: ModelHandle>, + focus: bool, + ctx: &mut AppContext, + ) -> TuiSessionId { + let id = TuiSessionId(view.id()); + if ctx.has_singleton_model::() { + let orchestration = TuiOrchestrationModel::handle(ctx); + ctx.subscribe_to_view(&view, move |_, event, ctx| match event { + TuiTerminalSessionEvent::StartAgentConversation { + request, + working_directory, + } => { + orchestration.update(ctx, |orchestration, ctx| { + orchestration.dispatch_create_agent( + id, + (**request).clone(), + working_directory.clone(), + ctx, + ); + }); + } + TuiTerminalSessionEvent::CleanupFailedChildLaunch { conversation_id } => { + orchestration.update(ctx, |orchestration, ctx| { + orchestration.cleanup_failed_child(conversation_id, ctx); + }); + } + TuiTerminalSessionEvent::ExecuteCommand(_) + | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::WriteAgentInput { .. } + | TuiTerminalSessionEvent::WriteUserInput(_) + | TuiTerminalSessionEvent::Resize(_) => {} + }); + } + sessions.update(ctx, |sessions, ctx| { + debug_assert!( + sessions.session(id).is_none(), + "a session must not be registered twice" + ); + sessions.sessions.push(TuiSession { + id, + view, + _manager: manager, + }); + if focus { + sessions.focus_session(id, ctx); + } + ctx.notify(); + id + }) + } + + /// Subscribes the session owner to orchestration lifecycle requests. + pub(crate) fn wire_orchestration( + sessions: &ModelHandle, + orchestration: &ModelHandle, + ctx: &mut AppContext, + ) { + let sessions = sessions.clone(); + let orchestration_for_events = orchestration.clone(); + ctx.subscribe_to_model(orchestration, move |_, event, ctx| match event { + TuiOrchestrationEvent::CreateLocalOzChildSession { + parent_session_id, + request, + model_id, + working_directory, + task_id, + conversation_name, + } => { + let window_id = sessions + .as_ref(ctx) + .session(*parent_session_id) + .expect("the dispatching parent session must remain registered") + .view() + .window_id(ctx); + let (session_id, session_view) = create_local_terminal_session( + &sessions, + window_id, + false, + working_directory.clone(), + ctx, + ); + orchestration_for_events.update(ctx, |orchestration, ctx| { + orchestration.register_local_oz_child_session( + MaterializedLocalOzChildSession { + parent_session_id: *parent_session_id, + session_id, + session_view, + request: (**request).clone(), + model_id: model_id.clone(), + task_id: *task_id, + conversation_name: conversation_name.clone(), + }, + ctx, + ); + }); + } + TuiOrchestrationEvent::RemoveChildSession(session_id) => { + sessions.update(ctx, |sessions, ctx| { + sessions.remove_session(*session_id, ctx); + }); + } + }); + } + /// Creates the app's session container. pub(crate) fn new( driver: TuiDriverHandle, @@ -101,32 +211,6 @@ impl TuiSessions { } } - /// Registers an eagerly-created session view and optionally focuses it. - pub(crate) fn add_session( - &mut self, - view: ViewHandle, - manager: ModelHandle>, - focus: bool, - ctx: &mut ModelContext, - ) -> TuiSessionId { - let id = TuiSessionId(view.id()); - debug_assert!( - self.session(id).is_none(), - "a session must not be registered twice" - ); - self.sessions.push(TuiSession { - id, - view, - _manager: manager, - }); - ctx.emit(TuiSessionsEvent::SessionAdded(id)); - if focus { - self.focus_session(id, ctx); - } - ctx.notify(); - id - } - /// Returns the process-level context used to create session views. pub(crate) fn surface_context(&self) -> (TuiExitSummaryHandle, bool) { ( @@ -142,6 +226,11 @@ impl TuiSessions { if self.sessions.len() == before { return; } + if ctx.has_singleton_model::() { + TuiOrchestrationModel::handle(ctx).update(ctx, |orchestration, ctx| { + orchestration.handle_session_removed(id, ctx); + }); + } ctx.emit(TuiSessionsEvent::SessionRemoved(id)); if self.focused_session_id == Some(id) { self.focused_session_id = None; diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs index 4e45d9010bf..49cdb1af86f 100644 --- a/crates/warp_tui/src/session_registry_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -24,7 +24,7 @@ fn capture_events(app: &mut App) -> CapturedEvents { } #[test] -fn add_and_focus_drive_events() { +fn focus_drives_events() { App::test((), |mut app| async move { register_tui_session_view_test_singletons(&mut app); add_test_semantic_selection(&mut app); @@ -45,17 +45,14 @@ fn add_and_focus_drive_events() { let (first, first_manager) = add_test_terminal_session(&mut app, window_id); let first_view_id = first.id(); - let first_id = app.update_model(&sessions, |sessions, ctx| { - sessions.add_session(first, first_manager, true, ctx) + let first_id = app.update(|ctx| { + TuiSessions::register_session(&sessions, first, first_manager, true, ctx) }); assert_eq!(first_id.surface_id(), first_view_id); assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); assert_eq!( std::mem::take(&mut *events.borrow_mut()), - vec![ - TuiSessionsEvent::SessionAdded(first_id), - TuiSessionsEvent::FocusChanged(first_id), - ], + vec![TuiSessionsEvent::FocusChanged(first_id)], ); let first_focused_view_id = app.read(|ctx| ctx.focused_view_id(window_id)); let (second, second_manager) = add_test_terminal_session(&mut app, window_id); @@ -65,14 +62,11 @@ fn add_and_focus_drive_events() { first_focused_view_id, ); - let second_id = app.update_model(&sessions, |sessions, ctx| { - sessions.add_session(second, second_manager, false, ctx) + let second_id = app.update(|ctx| { + TuiSessions::register_session(&sessions, second, second_manager, false, ctx) }); assert_eq!(second_id.surface_id(), second_view_id); - assert_eq!( - std::mem::take(&mut *events.borrow_mut()), - vec![TuiSessionsEvent::SessionAdded(second_id)], - ); + assert!(std::mem::take(&mut *events.borrow_mut()).is_empty()); assert_eq!( app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), Some(first_id), diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 0b280d751b7..943858f2b1e 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -23,39 +23,41 @@ converts executor events into semantic session events - `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. +`TuiSessions::register_session` owns the `AppContext` subscription to each +session view and forwards orchestration attempts into `TuiOrchestrationModel`. +`TuiSessions::wire_orchestration` subscribes the session owner to child-session +creation and removal intents from the orchestration model. Because every +session, including a background child session, passes through the same +registration boundary, children are also wired to launch descendants +(`crates/warp_tui/src/session_registry.rs (77-181)`). ### Native child launch -`TuiOrchestrationModel` separates task creation from TUI surface creation -(`crates/warp_tui/src/orchestration_model.rs (114-238)`): +`TuiOrchestrationModel` separates task creation from TUI-owned surface creation +(`crates/warp_tui/src/orchestration_model.rs (83-209)`): 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 +2. The orchestration model emits `CreateLocalOzChildSession` after preparation succeeds. +3. `TuiSessions` creates an unfocused terminal session using the parent's captured working + directory, then calls `register_local_oz_child_session`. +4. 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 +5. `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)`). +6. The coordinator registers event consumers for the parent and child conversations. +7. `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 (1092-1104)`). `create_local_terminal_session` is the single session factory for both the focused bootstrap session and background children. Callers pass the window from their existing view context rather than storing it in `TuiSessions`; child orchestration derives it from the requesting parent session. The factory's explicit startup-directory parameter preserves the parent's current directory -for child shells (`crates/warp_tui/src/session.rs (152-217)`). +for child shells (`crates/warp_tui/src/session.rs (190-243)`). ### 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 +The coordinator stores only orchestration runtime bookkeeping (`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. @@ -65,13 +67,14 @@ conversation also removes its id from `children_by_parent` ### 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)`). +(`crates/warp_tui/src/orchestration_model.rs (83-156, 211-255)`). 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. +- emits a removal intent for any mapped background session, +- lets `TuiSessions` remove that owned session, and +- unregisters consumers when `TuiSessions` reports the removal. This leaves no dead child conversation, session, or streamer registration. ### Transcript rendering `crates/warp_tui/src/agent_block.rs (775-888)`: From ccf1b222ec7eac4125e7105312f4d8e7c998748f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 13:10:23 -0400 Subject: [PATCH 05/10] avoid llm change emission --- app/src/ai/llms.rs | 30 +++++---- app/src/ai/llms_tests.rs | 78 ++++++++++++++++++++++ specs/code-1822-tui-local-children/TECH.md | 12 ++-- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index db173b281fe..0127490e6d0 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -728,10 +728,9 @@ struct AvailableLLMsUpdate { pub struct LLMPreferences { models_by_feature: ModelsByFeature, last_update: Option, - // Stores temporary model overrides for a given terminal view. - // NOTE: We only store an override if the model selected by the user is different - // from the base LLM for the active profile. This means that if the user selects the - // profile's default model and changes their profile, the model will update to that profile's default. + // Stores model overrides for a given terminal view. User selections are + // normalized against the GUI profile default, while explicit child-run + // selections remain pinned even when they currently equal the fallback. base_llm_for_terminal_view: HashMap, /// Synthetic `LLMInfo` entries built from the user's `ApiKeyManager.custom_endpoints` so /// custom models surface in the model picker and resolve through `info_for_id` lookups. @@ -1505,23 +1504,30 @@ 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. + /// Pins an explicit child-run model independently of profile or TUI + /// defaults. Persist the pin whenever it changes, but notify active-model + /// subscribers only when the surface's effective selection changes. pub(crate) fn set_agent_mode_llm_override( &mut self, terminal_view_id: EntityId, model_id: LLMId, ctx: &mut ModelContext, ) { - if self + let previous_effective_model_id = self + .get_active_base_model(ctx, Some(terminal_view_id)) + .id + .clone(); + let stored_selection_changed = self .base_llm_for_terminal_view .insert(terminal_view_id, model_id.clone()) - != Some(model_id) - { + != Some(model_id); + if stored_selection_changed { self.trigger_snapshot_save(ctx); - ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); + if self.get_active_base_model(ctx, Some(terminal_view_id)).id + != previous_effective_model_id + { + ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); + } } } diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index c2227f6914d..82ec23fdd4c 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -1,3 +1,6 @@ +use std::cell::Cell; +use std::rc::Rc; + use warpui::App; use super::*; @@ -955,6 +958,81 @@ fn tui_surface_override_precedes_file_backed_default() { }); } +#[test] +fn explicit_child_model_pin_preserves_gui_behavior_and_only_emits_for_effective_changes() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(UserWorkspaces::default_mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(TeamTesterStatus::mock); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(UpdateManager::mock); + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + let profiles = app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + let preferences = app.add_singleton_model(|_| preferences_for_tui_tests()); + let active_model_events = Rc::new(Cell::new(0)); + let captured_events = active_model_events.clone(); + app.update(|ctx| { + ctx.subscribe_to_model(&preferences, move |_, event, _| { + if matches!(event, LLMPreferencesEvent::UpdatedActiveAgentModeLLM) { + captured_events.set(captured_events.get() + 1); + } + }); + }); + + let surface_id = EntityId::new(); + preferences.update(&mut app, |preferences, ctx| { + preferences.set_agent_mode_llm_override(surface_id, LLMId::from("auto"), ctx); + }); + assert_eq!(active_model_events.get(), 0); + preferences.read(&app, |preferences, ctx| { + assert_eq!( + preferences + .get_active_base_model(ctx, Some(surface_id)) + .id + .as_str(), + "auto" + ); + assert_eq!( + preferences + .base_llm_for_terminal_view + .get(&surface_id) + .map(LLMId::as_str), + Some("auto") + ); + }); + + profiles.update(&mut app, |profiles, ctx| { + let profile_id = *profiles.active_profile(Some(surface_id), ctx).id(); + profiles.set_base_model(profile_id, Some(LLMId::from("claude-opus")), ctx); + }); + preferences.read(&app, |preferences, ctx| { + assert_eq!( + preferences + .get_active_base_model(ctx, Some(surface_id)) + .id + .as_str(), + "auto" + ); + }); + + preferences.update(&mut app, |preferences, ctx| { + preferences.set_agent_mode_llm_override(surface_id, LLMId::from("claude-opus"), ctx); + }); + assert_eq!(active_model_events.get(), 1); + preferences.update(&mut app, |preferences, ctx| { + preferences.set_agent_mode_llm_override(surface_id, LLMId::from("claude-opus"), ctx); + }); + assert_eq!(active_model_events.get(), 1); + }); +} + #[test] fn tui_agent_model_unknown_id_falls_back_to_the_default_model() { tui_agent_model_test(|preferences, app| { diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 943858f2b1e..509fdbeb77a 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -53,9 +53,11 @@ explicit startup-directory parameter preserves the parent's current directory for child shells (`crates/warp_tui/src/session.rs (190-243)`). ### 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)`). +Explicit child model selections remain pinned per surface even when they +currently equal the GUI profile or TUI file-backed fallback, so later default +changes cannot alter a running child. Installing or changing that pin persists +the selection, but emits the existing active-model event only when the +surface's effective model changes (`app/src/ai/llms.rs (844-878, 1470-1542)`). ### Streamer and session ownership The coordinator stores only orchestration runtime bookkeeping (`crates/warp_tui/src/orchestration_model.rs (31-39)`): @@ -101,8 +103,8 @@ types remain behind the shared launch API. 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/llms_tests.rs (936-1035)` verifies explicit child pins preserve GUI pane behavior, + suppress redundant model-change events, and precede 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. From f9f136a43321b9bd3c24c3cb6d713cb580c099c7 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 13:24:11 -0400 Subject: [PATCH 06/10] fix root deletion issue --- app/src/ai/blocklist/history_model.rs | 1 + app/src/ai/blocklist/history_model_tests.rs | 34 +++++++++++++++++++++ crates/warp_tui/src/agent_block_tests.rs | 8 +++-- crates/warp_tui/src/orchestration_model.rs | 6 ++-- specs/code-1822-tui-local-children/TECH.md | 4 +-- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index c3254bdd863..5962e94f4a3 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -2180,6 +2180,7 @@ impl BlocklistAIHistoryModel { self.all_conversations_metadata.remove(&conversation_id); self.conversations_by_id.remove(&conversation_id); + self.children_by_parent.remove(&conversation_id); self.children_by_parent.retain(|_, child_ids| { child_ids.retain(|child_id| *child_id != conversation_id); !child_ids.is_empty() diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index ed57f5f61de..f092db53b2e 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -1952,6 +1952,40 @@ fn test_remove_child_conversation_cleans_parent_index() { }); } +#[test] +fn test_remove_parent_conversation_cleans_incoming_and_outgoing_index_entries() { + App::test((), |mut app| async move { + let terminal_view_id = EntityId::new(); + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let grandparent_id = history_model.update(&mut app, |model, ctx| { + model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let parent_id = history_model.update(&mut app, |model, ctx| { + let parent_id = + model.start_new_conversation(terminal_view_id, false, false, false, ctx); + model.set_parent_for_conversation(parent_id, grandparent_id); + parent_id + }); + 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(parent_id, terminal_view_id, ctx); + }); + + history_model.read(&app, |model, _| { + assert!(model.conversation(&parent_id).is_none()); + assert!(model.conversation(&child_id).is_some()); + assert!(model.child_conversation_ids_of(&grandparent_id).is_empty()); + 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/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index ed33fe89e2f..7d67021d2f0 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -363,11 +363,13 @@ fn orchestration_outputs_render_without_wait_for_events_tool_row() { assert_eq!( block.sections(app_ctx), vec![ - TuiAIBlockSection::PlainText( + TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( "Received message from agent researcher: Investigation complete" .to_string(), - ), - TuiAIBlockSection::PlainText("Received 2 agent lifecycle events".to_string(),), + )), + TuiAIBlockSection::RichText(TuiRichTextSection::PlainText( + "Received 2 agent lifecycle events".to_string(), + )), ], ); assert_eq!( diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 21ad7f0cb49..15d60927c07 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -231,10 +231,8 @@ impl TuiOrchestrationModel { message: String, ctx: &mut ModelContext, ) { - log::warn!( - "Failing TUI child agent request '{}': {message}", - request.name - ); + let request_id = request.id; + log::warn!("Failing TUI child agent request: request_id={request_id:?}"); let surface_id = EntityId::new(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { let conversation_id = history.start_new_child_conversation( diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md index 509fdbeb77a..6bf0a95d383 100644 --- a/specs/code-1822-tui-local-children/TECH.md +++ b/specs/code-1822-tui-local-children/TECH.md @@ -105,8 +105,8 @@ types remain behind the shared launch API. counts while asserting that `WaitForEvents` contributes no tool row. - `app/src/ai/llms_tests.rs (936-1035)` verifies explicit child pins preserve GUI pane behavior, suppress redundant model-change events, and precede 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. +- `app/src/ai/blocklist/history_model_tests.rs (1930-1991)` verifies that removing a conversation + cleans both its incoming child reference and its outgoing 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. From 0e9042fd31b8825a4cb881fa7a0871ed76864ebb Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 14:50:43 -0400 Subject: [PATCH 07/10] move session creation into session registry --- crates/warp_tui/src/session.rs | 76 ++++--------------------- crates/warp_tui/src/session_registry.rs | 75 ++++++++++++++++++++---- 2 files changed, 75 insertions(+), 76 deletions(-) diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 1c40134e344..2bfa487620e 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -4,36 +4,25 @@ //! initialization is done, the mount built here starts the TUI driver and //! defers creating the first terminal session until login. -use std::collections::HashMap; -use std::ffi::OsString; -use std::path::PathBuf; - use anyhow::{Context, Result}; use clap::error::ErrorKind; use clap::Parser; -use pathfinder_geometry::vector::Vector2F; -use warp::tui_export::{ - Appearance, BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, - ServerConversationToken, TerminalSurfaceResult, -}; +use warp::tui_export::{Appearance, ServerConversationToken}; use warp::{TuiLoginEvent, TuiLoginModel, TuiLoginPhase}; use warp_core::telemetry::TelemetryEvent as _; use warp_errors::report_error; use warpui::SingletonEntity as _; use warpui_core::platform::{TerminationMode, WindowStyle}; use warpui_core::runtime::spawn_tui_driver; -use warpui_core::{AddWindowOptions, AppContext, ModelHandle, ViewHandle, WindowId}; +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::{TuiSessionId, TuiSessions, TuiSessionsEvent}; +use crate::session_registry::{TuiSessions, TuiSessionsEvent}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; -use crate::terminal_session_view::{ - TuiConversationRestoreOrigin, TuiConversationRestoreTarget, TuiTerminalSessionView, -}; -use crate::transcript_view::TRANSCRIPT_BLOCK_SPACING; +use crate::terminal_session_view::{TuiConversationRestoreOrigin, TuiConversationRestoreTarget}; #[derive(Parser)] #[command(name = "warp-tui")] @@ -171,8 +160,13 @@ fn create_terminal_session_after_login( let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); let window_id = root.window_id(ctx); - let (_, surface) = - create_local_terminal_session(sessions, window_id, true, std::env::current_dir().ok(), ctx); + let (_, surface) = TuiSessions::create_local_terminal_session( + sessions, + window_id, + true, + std::env::current_dir().ok(), + ctx, + ); if let Some(token) = resume_token { surface.update(ctx, |view, ctx| { view.restore_conversation( @@ -185,54 +179,6 @@ fn create_terminal_session_after_login( 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, - window_id: WindowId, - focus: bool, - startup_directory: Option, - ctx: &mut AppContext, -) -> (TuiSessionId, ViewHandle) { - let (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( - startup_directory, - HashMap::::from_iter(std::env::vars_os()), - IsSharedSessionCreator::No, - None, - banner.clone(), - Vector2F::new(120., 24.), - None, - None, - TRANSCRIPT_BLOCK_SPACING, - ctx, - move |surface_init, ctx| { - let surface = ctx.add_typed_action_tui_view(window_id, |ctx| { - TuiTerminalSessionView::new( - surface_init, - exit_summary, - keyboard_enhancement_supported, - ctx, - ) - }); - TerminalSurfaceResult { - surface, - post_wire: move |_manager: &mut LocalTtyTerminalManager, - _surface: &ViewHandle, - _ctx: &mut AppContext| {}, - } - }, - ); - - let surface = manager.surface.clone(); - let session_id = - TuiSessions::register_session(sessions, manager.surface, manager.manager, focus, ctx); - (session_id, surface) -} - #[cfg(test)] #[path = "session_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index bfbccd7d531..bb2e9c3d817 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -3,18 +3,25 @@ //! Every session is a full [`TuiTerminalSessionView`] backed by a retained //! terminal manager. The container owns session lifetime and focus; the root //! view renders and routes input only to the focused session. +use std::collections::HashMap; +use std::ffi::OsString; +use std::path::PathBuf; -use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; +use pathfinder_geometry::vector::Vector2F; +use warp::tui_export::{ + BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, ServerConversationToken, + TerminalManagerTrait, TerminalSurfaceResult, +}; use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; -use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle, WindowId}; use crate::orchestration_model::{ MaterializedLocalOzChildSession, TuiOrchestrationEvent, TuiOrchestrationModel, }; use crate::resume::TuiExitSummaryHandle; -use crate::session::create_local_terminal_session; use crate::terminal_session_view::{TuiTerminalSessionEvent, TuiTerminalSessionView}; +use crate::transcript_view::TRANSCRIPT_BLOCK_SPACING; /// Identifies a TUI terminal session. /// @@ -73,6 +80,59 @@ impl Entity for TuiSessions { impl SingletonEntity for TuiSessions {} impl TuiSessions { + /// Creates and registers a full local terminal session. + pub(crate) fn create_local_terminal_session( + sessions: &ModelHandle, + window_id: WindowId, + focus: bool, + startup_directory: Option, + ctx: &mut AppContext, + ) -> (TuiSessionId, ViewHandle) { + let (exit_summary, keyboard_enhancement_supported) = sessions.read(ctx, |sessions, _| { + ( + sessions.exit_summary.clone(), + sessions.keyboard_enhancement_supported, + ) + }); + // 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( + startup_directory, + HashMap::::from_iter(std::env::vars_os()), + IsSharedSessionCreator::No, + None, + banner.clone(), + Vector2F::new(120., 24.), + None, + None, + TRANSCRIPT_BLOCK_SPACING, + ctx, + move |surface_init, ctx| { + let surface = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new( + surface_init, + exit_summary, + keyboard_enhancement_supported, + ctx, + ) + }); + TerminalSurfaceResult { + surface, + post_wire: move |_manager: &mut LocalTtyTerminalManager< + TuiTerminalSessionView, + >, + _surface: &ViewHandle, + _ctx: &mut AppContext| {}, + } + }, + ); + + let surface = manager.surface.clone(); + let session_id = + Self::register_session(sessions, manager.surface, manager.manager, focus, ctx); + (session_id, surface) + } /// Wires a session view to orchestration before registering it. pub(crate) fn register_session( sessions: &ModelHandle, @@ -151,7 +211,7 @@ impl TuiSessions { .expect("the dispatching parent session must remain registered") .view() .window_id(ctx); - let (session_id, session_view) = create_local_terminal_session( + let (session_id, session_view) = Self::create_local_terminal_session( &sessions, window_id, false, @@ -211,13 +271,6 @@ impl TuiSessions { } } - /// Returns the process-level context used to create session views. - pub(crate) fn surface_context(&self) -> (TuiExitSummaryHandle, bool) { - ( - self.exit_summary.clone(), - 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) { From 295c8279f5f93d3231fc1c30eca2edc31033e63e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 15:01:34 -0400 Subject: [PATCH 08/10] fix test --- crates/warp_tui/src/orchestration_model_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 20a6ba93148..e03197bbd73 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -9,9 +9,7 @@ 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, -}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; struct OrchestrationFixture { sessions: ModelHandle, @@ -54,7 +52,6 @@ fn add_dispatching_session( let session_id = app.update(|ctx| { TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx) }); - add_active_test_conversation(app, session_id.surface_id()); session_id } From 7e2d5f8f253efc96051db6a6ec9e9c5de34d648c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 15:16:43 -0400 Subject: [PATCH 09/10] fix test issue --- crates/warp_tui/src/orchestration_model_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index e03197bbd73..1d4b7e33ddb 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -49,10 +49,7 @@ fn add_dispatching_session( focus: bool, ) -> TuiSessionId { let (session, manager) = add_test_terminal_session(app, fixture.window_id); - let session_id = app.update(|ctx| { - TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx) - }); - session_id + app.update(|ctx| TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx)) } /// Creates a standalone executor and relays its frontend materialization From 8496baf45ad4af42ef6028f721ca45f56f57d5bd Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 15:28:25 -0400 Subject: [PATCH 10/10] fix wasm issues --- app/src/ai/blocklist/child_agent_launch.rs | 5 ++++- app/src/ai/blocklist/mod.rs | 6 ++++-- app/src/ai/llms.rs | 1 + app/src/pane_group/pane/terminal_pane.rs | 8 +++----- app/src/tui_export.rs | 14 ++++++++------ 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/ai/blocklist/child_agent_launch.rs b/app/src/ai/blocklist/child_agent_launch.rs index 998ca708d54..17915d3c3c5 100644 --- a/app/src/ai/blocklist/child_agent_launch.rs +++ b/app/src/ai/blocklist/child_agent_launch.rs @@ -10,7 +10,9 @@ use { crate::server::server_api::ServerApiProvider, }; -use crate::ai::llms::{LLMId, LLMPreferences}; +#[cfg(not(target_family = "wasm"))] +use crate::ai::llms::LLMId; +use crate::ai::llms::LLMPreferences; use crate::AIExecutionProfilesModel; /// Server-side state prepared before a frontend creates the child's surface. @@ -78,6 +80,7 @@ pub fn inherit_child_agent_settings( /// Applies a non-empty run-wide model override after parent settings have /// been inherited. +#[cfg(not(target_family = "wasm"))] pub fn apply_child_agent_model_override( child_surface_id: EntityId, model_id: Option<&str>, diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index b628519bc5f..88641c44fae 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -72,10 +72,12 @@ pub use action_model::{ 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}; +pub use child_agent_launch::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}; +pub use child_agent_launch::{ + apply_child_agent_model_override, 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 0127490e6d0..a421bae4e1f 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -1507,6 +1507,7 @@ impl LLMPreferences { /// Pins an explicit child-run model independently of profile or TUI /// defaults. Persist the pin whenever it changes, but notify active-model /// subscribers only when the surface's effective selection changes. + #[cfg(not(target_family = "wasm"))] pub(crate) fn set_agent_mode_llm_override( &mut self, terminal_view_id: EntityId, diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index fafc1f3ff71..963b276c050 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -29,13 +29,11 @@ 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::{ - apply_child_agent_model_override, BlocklistAIHistoryModel, StartAgentRequest, -}; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::{apply_child_agent_model_override, prepare_local_oz_child_launch}; +use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; use crate::ai::skills::SkillManager; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index f7f0a4a6011..1941e4a26e7 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -68,16 +68,18 @@ 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; +#[cfg(not(target_family = "wasm"))] pub use crate::ai::blocklist::{ - apply_child_agent_model_override, block_context_from_terminal_model, - inherit_child_agent_settings, prepare_local_oz_child_launch, AIActionStatus, + apply_child_agent_model_override, prepare_local_oz_child_launch, PreparedLocalOzChildLaunch, +}; +pub use crate::ai::blocklist::{ + block_context_from_terminal_model, inherit_child_agent_settings, AIActionStatus, AskUserQuestionExecutor, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, PreparedLocalOzChildLaunch, RequestFileEditsExecutor, RunAgentsExecutor, - RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, - ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, - StartAgentRequest, StartAgentRequestId, + PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,