diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index 379edcc7be7..50947a57a0f 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -14,7 +14,6 @@ use ai::agent::orchestration_config::OrchestrationConfig; use ai::skills::SkillReference; use futures::future::BoxFuture; use futures::FutureExt; -use settings::Setting; use warp_cli::agent::Harness; use warp_core::execution_mode::AppExecutionMode; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; @@ -26,14 +25,15 @@ use crate::ai::agent::{ AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput, StartAgentExecutionMode, }; -use crate::ai::auth_secret_types::auth_secret_types_for_harness; -use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState; use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; -use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::document::plan_publication::{ prepare_plan_publications, wait_for_plan_publications, }; use crate::ai::local_harness_setup::local_harness_product_disabled_message; +use crate::ai::orchestration::{ + can_execute_with_auth_secret, populate_default_auth_secret_for_execution, + OrchestrationConfigState, +}; /// Per-child spawn timeout. If a child agent doesn't report back within /// this window (e.g. binary not found, server error), the slot is failed @@ -610,80 +610,21 @@ fn normalize_agent_name(name: &str) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase()) } -fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> bool { - if !request.execution_mode.is_remote() { - return false; - } - let Some(harness) = Harness::parse_orchestration_harness(&request.harness_type) else { - return false; - }; - harness != Harness::Oz && !auth_secret_types_for_harness(harness).is_empty() -} - -fn can_execute_with_auth_secret( - request: &RunAgentsRequest, - ctx: &ModelContext, -) -> bool { - if !requires_default_auth_secret_for_execution(request) { - return true; - } - if request - .harness_auth_secret_name - .as_deref() - .is_some_and(|name| !name.trim().is_empty()) - { - return true; - } - default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some() -} - -fn default_auth_secret_name_for_harness( - harness_type: &str, - ctx: &ModelContext, -) -> Option { - let harness = Harness::parse_orchestration_harness(harness_type)?; - if harness == Harness::Oz { - return None; - } - CloudAgentSettings::as_ref(ctx) - .last_selected_auth_secret - .value() - .get(harness.config_name()) - .cloned() - .filter(|name| !name.trim().is_empty()) -} - -fn populate_default_auth_secret_for_execution( - request: &mut RunAgentsRequest, - ctx: &ModelContext, -) { - if !requires_default_auth_secret_for_execution(request) - || request - .harness_auth_secret_name - .as_deref() - .is_some_and(|name| !name.trim().is_empty()) - { - return; - } - request.harness_auth_secret_name = - default_auth_secret_name_for_harness(&request.harness_type, ctx); -} - /// Unconditionally overrides run-wide fields on a `RunAgentsRequest` /// from the approved orchestration config, delegating to -/// `OrchestrationEditState::override_from_approved_config`. +/// `OrchestrationConfigState::override_from_approved_config`. fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) { // The approved plan config is the source of truth for these run-wide fields, // so callers pass a mutable request and continue with the normalized value. - let mut edit_state = OrchestrationEditState::from_run_agents_fields( + let mut config_state = OrchestrationConfigState::from_run_agents_fields( &request.model_id, &request.harness_type, &request.execution_mode, ); - edit_state.override_from_approved_config(config); - request.model_id = edit_state.model_id; - request.harness_type = edit_state.harness_type; - request.execution_mode = edit_state.execution_mode; + config_state.override_from_approved_config(config); + request.model_id = config_state.model_id; + request.harness_type = config_state.harness_type; + request.execution_mode = config_state.execution_mode; } /// Defence-in-depth validation; mirrors the card view's diff --git a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs index bec3a44fe46..3a66f986af6 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs @@ -20,6 +20,7 @@ use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentSaveStat use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::execution_profiles::RunAgentsPermission; use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; +use crate::ai::orchestration::populate_default_auth_secret_for_execution; use crate::appearance::Appearance; use crate::auth::AuthStateProvider; use crate::cloud_object::model::persistence::CloudModel; diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 29f072cb3b7..49cbc2fdff6 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -6,16 +6,11 @@ //! consumers impl [`OrchestrationControlAction`] to provide the mapping //! from field-change events to their own action enum. -use std::collections::HashMap; - use ai::agent::action::RunAgentsExecutionMode; -use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::{vec2f, Vector2F}; -use settings::Setting; use warp_cli::agent::Harness; use warp_core::ui::theme::Fill; -use warp_errors::report_if_error; use warpui::elements::{ Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, @@ -32,16 +27,24 @@ use warpui::{ use crate::ai::auth_secret_types::auth_secret_types_for_harness; use crate::ai::blocklist::inline_action::host_picker::HostPicker; -use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; -use crate::ai::connected_self_hosted_workers::{ConnectedSelfHostedWorkersModel, WARP_WORKER_HOST}; +use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; use crate::ai::harness_display; -use crate::ai::llms::LLMInfo; use crate::ai::local_harness_setup::{ - local_harness_is_product_enabled, local_harness_product_disabled_message, - local_harness_setup_state, LocalHarnessSetupState, + local_harness_is_product_enabled, local_harness_setup_state, LocalHarnessSetupState, +}; +pub use crate::ai::orchestration::{ + accept_disabled_reason_with_auth, empty_env_recommendation_message, + persist_environment_selection, persist_host_selection, + resolve_auth_secret_selection_for_harness, resolve_default_environment_id, + resolve_default_host_slug, should_show_auth_secret_picker, AuthSecretSelection, + OrchestrationConfigState, OrchestrationEditState, ORCHESTRATION_WARP_WORKER_HOST, +}; +use crate::ai::orchestration::{ + get_base_model_choices, harness_is_selectable, persist_auth_secret_selection, + resolve_recent_host_slug, ORCHESTRATION_ENV_NONE_LABEL, }; use crate::appearance::Appearance; use crate::cloud_object::CloudObjectLookup as _; @@ -52,18 +55,10 @@ use crate::view_components::dropdown::{ Dropdown, DropdownAction, DropdownItemAction, DropdownStyle, }; use crate::view_components::FilterableDropdown; -use crate::workspaces::user_workspaces::UserWorkspaces; use crate::LLMPreferences; -/// Env var override for the workspace default host (developer testing). -/// Mirrors the single-agent ambient flow. -const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; - // ── Shared constants ──────────────────────────────────────────────── -pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST; -pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment"; - pub const ORCHESTRATION_PICKER_HEIGHT: f32 = 36.; pub const ORCHESTRATION_PICKER_BORDER_WIDTH: f32 = 1.; pub const ORCHESTRATION_PICKER_FONT_SIZE: f32 = 14.; @@ -98,253 +93,6 @@ pub trait OrchestrationControlAction: DropdownItemAction + Clone { fn create_new_auth_secret_requested() -> Self; } -// ── Shared edit state ─────────────────────────────────────────────── - -/// The user's current selection in the auth secret picker. Only `Named(_)` -/// is persisted across sessions; the other variants are per-session. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthSecretSelection { - /// No choice yet; re-seeded from persisted settings. Blocks Accept. - Unset, - /// User explicitly chose to inherit credentials from the worker env. - Inherit, - /// User picked a managed secret by name. - Named(String), - /// Creating a key (modal open). Blocks Accept and, unlike `Unset`, is - /// not re-seeded from persisted settings. - CreatingNew, -} - -impl AuthSecretSelection { - /// `Some(name)` → `Named`, `None` → `Unset`. Wire payloads and persisted - /// settings carry only the name, so absence always means "no choice yet". - pub fn from_optional_name(name: Option) -> Self { - match name { - Some(name) if !name.trim().is_empty() => Self::Named(name), - _ => Self::Unset, - } - } -} - -/// Run-wide configuration fields shared between the confirmation card -/// editor and the plan-card config block. Card-specific fields -/// (agent_run_configs, base_prompt, summary, skills) -/// remain on the per-view state structs. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OrchestrationEditState { - pub model_id: String, - pub harness_type: String, - pub execution_mode: RunAgentsExecutionMode, - /// Drives the picker display and Accept gate. Persisted as - /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. - pub auth_secret_selection: AuthSecretSelection, -} - -impl OrchestrationEditState { - /// Returns the on-wire secret name; `None` for `Inherit`, `Unset`, or - /// when the current mode/harness doesn't support managed auth secrets - /// (Local, Oz, or harnesses without managed-secret types). Gating on - /// visibility here prevents a stale `Named(_)` left over from a prior - /// Cloud/non-Oz config from leaking into the on-wire payload after the - /// user toggles to Local or switches to a harness without auth. - pub fn auth_secret_name(&self) -> Option<&str> { - if !should_show_auth_secret_picker(self) { - return None; - } - match &self.auth_secret_selection { - AuthSecretSelection::Named(name) => Some(name.as_str()), - AuthSecretSelection::Inherit - | AuthSecretSelection::Unset - | AuthSecretSelection::CreatingNew => None, - } - } - - /// User picked "New API key…"; mark `CreatingNew` to block Accept until a - /// key is created or another option is chosen. - pub fn select_create_new_auth_secret(&mut self) { - self.auth_secret_selection = AuthSecretSelection::CreatingNew; - } -} - -impl OrchestrationEditState { - pub(crate) fn sanitize_for_local_execution(&mut self) { - let Some(harness) = Harness::parse_local_child_harness(&self.harness_type) else { - return; - }; - if local_harness_product_disabled_message(harness).is_some() { - self.harness_type = "oz".to_string(); - self.model_id.clear(); - } - } - pub fn from_run_agents_fields( - model_id: &str, - harness_type: &str, - execution_mode: &RunAgentsExecutionMode, - ) -> Self { - Self { - model_id: model_id.to_string(), - harness_type: harness_type.to_string(), - execution_mode: execution_mode.clone(), - auth_secret_selection: AuthSecretSelection::Unset, - } - } - - pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self { - let execution_mode = match &config.execution_mode { - OrchestrationExecutionMode::Local => RunAgentsExecutionMode::Local, - OrchestrationExecutionMode::Remote { - environment_id, - worker_host, - } => RunAgentsExecutionMode::Remote { - environment_id: environment_id.clone(), - worker_host: worker_host.clone(), - computer_use_enabled: false, - }, - }; - let mut state = Self { - model_id: config.model_id.clone(), - harness_type: config.harness_type.clone(), - execution_mode, - auth_secret_selection: AuthSecretSelection::Unset, - }; - if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { - state.sanitize_for_local_execution(); - } - state - } - - /// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching - /// to Cloud (unsupported combination). - pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) { - if is_remote { - if self.harness_type.eq_ignore_ascii_case("opencode") { - self.harness_type = "oz".to_string(); - } - if !self.execution_mode.is_remote() { - self.execution_mode = RunAgentsExecutionMode::Remote { - environment_id: String::new(), - worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), - computer_use_enabled: false, - }; - } - } else { - self.execution_mode = RunAgentsExecutionMode::Local; - self.sanitize_for_local_execution(); - } - } - - pub fn set_environment_id(&mut self, environment_id: String) { - if let RunAgentsExecutionMode::Remote { - environment_id: id, .. - } = &mut self.execution_mode - { - *id = environment_id; - } - } - - pub fn set_worker_host(&mut self, worker_host: String) { - if let RunAgentsExecutionMode::Remote { - worker_host: wh, .. - } = &mut self.execution_mode - { - *wh = worker_host; - } - } - - /// Returns `Some(reason)` if Accept / Apply must be disabled. - /// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses. - pub fn accept_disabled_reason(&self) -> Option<&'static str> { - match &self.execution_mode { - RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type) - .and_then(local_harness_product_disabled_message), - RunAgentsExecutionMode::Remote { .. } - if self.harness_type.eq_ignore_ascii_case("opencode") => - { - Some( - "OpenCode is not supported on Cloud yet. Switch to Local or pick a different harness.", - ) - } - RunAgentsExecutionMode::Remote { .. } => None, - } - } - - /// Fills in empty fields from the approved orchestration config. - /// When the LLM omits harness/model/execution_mode to inherit from - /// the active config, the raw request arrives with defaults (empty - /// harness, empty model, Local mode). This resolves those to the - /// config values so the UI shows the intended settings. - pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) { - if self.harness_type.is_empty() && !config.harness_type.is_empty() { - self.harness_type = config.harness_type.clone(); - } - if self.model_id.is_empty() && !config.model_id.is_empty() { - self.model_id = config.model_id.clone(); - } - if !self.execution_mode.is_remote() && config.execution_mode.is_remote() { - self.execution_mode = Self::from_orchestration_config(config).execution_mode; - } - if matches!(self.execution_mode, RunAgentsExecutionMode::Local) { - self.sanitize_for_local_execution(); - } - } - - /// Unconditionally overrides model, harness, and execution mode - /// from the approved orchestration config. The plan config is the - /// user-approved source of truth — the LLM's run_agents call may - /// omit or set these differently, but the config always wins. - /// - /// `computer_use_enabled` is preserved from the current state when - /// both sides are Remote, since it is a per-call flag set by the LLM. - pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) { - self.model_id = config.model_id.clone(); - self.harness_type = config.harness_type.clone(); - - let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { - ( - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - OrchestrationExecutionMode::Remote { .. }, - ) => Some(*computer_use_enabled), - _ => None, - }; - - self.execution_mode = Self::from_orchestration_config(config).execution_mode; - - if let ( - Some(cue), - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - ) = (preserve_computer_use, &mut self.execution_mode) - { - *computer_use_enabled = cue; - } - } - - /// Converts to a native `OrchestrationConfig` for storage / match. - pub fn to_orchestration_config(&self) -> OrchestrationConfig { - let execution_mode = match &self.execution_mode { - RunAgentsExecutionMode::Local => OrchestrationExecutionMode::Local, - RunAgentsExecutionMode::Remote { - environment_id, - worker_host, - .. - } => OrchestrationExecutionMode::Remote { - environment_id: environment_id.clone(), - worker_host: worker_host.clone(), - }, - }; - OrchestrationConfig { - model_id: self.model_id.clone(), - harness_type: self.harness_type.clone(), - execution_mode, - } - } -} - // ── Picker handles ────────────────────────────────────────────────── /// Picker view handles shared between card editor and plan-card config @@ -473,16 +221,6 @@ pub fn new_standard_filterable_picker_dropdown( - llm_prefs: &'a LLMPreferences, - app: &'a AppContext, - is_local: bool, -) -> impl Iterator { - llm_prefs - .get_base_llm_choices_for_agent_mode(app) - .filter(move |llm| is_local || llm_prefs.custom_llm_info_for_id(&llm.id).is_none()) -} /// Populates the model picker based on the active harness. /// /// - **Oz / empty**: shows the Warp LLM catalog (existing behavior). @@ -594,65 +332,6 @@ fn default_model_menu_item() -> MenuItem( - model_id: &str, - harness_type: &str, - is_local: bool, - ctx: &mut ViewContext, -) -> bool { - let harness = Harness::parse_orchestration_harness(harness_type); - match harness { - Some(Harness::Oz) | None => { - let llm_prefs = LLMPreferences::as_ref(ctx); - get_base_model_choices(llm_prefs, ctx, is_local) - .any(|llm| llm.id.to_string() == model_id) - } - Some(Harness::Codex) if is_local => model_id.is_empty(), - Some(harness) => { - // Empty string is always valid (the "Default model" entry). - if model_id.is_empty() { - return true; - } - let availability = HarnessAvailabilityModel::as_ref(ctx); - availability - .models_for(harness) - .is_some_and(|models| models.iter().any(|m| m.id == model_id)) - } - } -} - -/// Returns the default model_id for the given harness. -/// -/// For Oz this is the first Warp LLM; for non-Oz harnesses it is an empty -/// string (the "Default model" entry). -pub fn first_filtered_model_id( - harness_type: &str, - ctx: &mut ViewContext, -) -> Option { - let harness = Harness::parse_orchestration_harness(harness_type); - match harness { - Some(Harness::Oz) | None => { - let llm_prefs = LLMPreferences::as_ref(ctx); - llm_prefs - .get_base_llm_choices_for_agent_mode(ctx) - .next() - .map(|llm| llm.id.to_string()) - } - Some(_) => Some(String::new()), - } -} - -fn should_show_harness_picker(_state: &OrchestrationEditState) -> bool { - true -} - -fn local_harness_setup_is_ready(harness: Harness, is_local: bool) -> bool { - !is_local || local_harness_setup_state(harness).is_selectable() -} - pub fn populate_harness_picker( dropdown: &ViewHandle>, initial_harness: &str, @@ -692,7 +371,7 @@ pub fn populate_harness_picker( .collect(); sorted.sort_by_key(|entry| { let harness = resolve_entry_harness(entry.harness, &entry.display_name); - !(entry.enabled && local_harness_setup_is_ready(harness, is_local)) + !(entry.enabled && harness_is_selectable(harness, is_local)) }); // Resolve the target harness so we can match by enum variant @@ -720,7 +399,7 @@ pub fn populate_harness_picker( fields = fields.with_override_icon_color(Fill::from(color)); } let harness_str = harness.to_string(); - let selectable = entry.enabled && local_harness_setup_is_ready(harness, is_local); + let selectable = entry.enabled && harness_is_selectable(harness, is_local); if selectable { fields = fields.with_on_select_action(DropdownAction::select_action_and_close( A::harness_changed(harness_str.clone()), @@ -937,246 +616,8 @@ pub fn populate_host_picker( }); } -/// Resolves the workspace-configured default host slug, honoring the -/// `WARP_CLOUD_MODE_DEFAULT_HOST` env var override for developer -/// testing. Mirrors the single-agent ambient flow. -pub fn resolve_default_host_slug(ctx: &AppContext) -> Option { - if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) { - let trimmed = slug.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - UserWorkspaces::as_ref(ctx) - .default_host_slug() - .map(str::to_string) - .filter(|s| !s.trim().is_empty()) -} - -/// Returns the user's last-selected custom host slug from -/// `CloudAgentSettings.last_selected_host`, excluding `"warp"` and the -/// workspace default (those are surfaced as separate menu rows). -pub fn resolve_recent_host_slug(ctx: &AppContext) -> Option { - let last = CloudAgentSettings::as_ref(ctx) - .last_selected_host - .value() - .clone() - .filter(|s| !s.trim().is_empty())?; - if last.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { - return None; - } - if resolve_default_host_slug(ctx).as_deref() == Some(last.as_str()) { - return None; - } - Some(last) -} - -/// Persists the user's most-recent host selection to -/// `CloudAgentSettings.last_selected_host`. Skipped for `"warp"` and -/// empty values (those don't represent a custom slug worth remembering). -pub fn persist_host_selection(worker_host: &str, ctx: &mut ViewContext) { - let trimmed = worker_host.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { - return; - } - let value = trimmed.to_string(); - CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.last_selected_host.set_value(Some(value), ctx)); - }); -} - -/// Normalizes a harness_type string for use as a HashMap key in -/// per-harness model memory. Empty string (the wire representation -/// of Oz) is mapped to "oz" so saves and lookups are consistent. -pub fn harness_save_key(harness_type: &str) -> &str { - if harness_type.is_empty() { - "oz" - } else { - harness_type - } -} - -// ── Default environment resolution ────────────────────────────────── - -/// Resolves a default environment ID using the same logic as the -/// `/cloud-agent` environment selector: first tries the user's -/// last-selected environment from settings, then falls back to the -/// most recently used environment. -pub fn resolve_default_environment_id(ctx: &AppContext) -> Option { - if let Some(env_id) = *CloudAgentSettings::as_ref(ctx) - .last_selected_environment_id - .value() - { - if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() { - return Some(env_id.uid()); - } - } - let mut envs = CloudAmbientAgentEnvironment::get_all(ctx); - envs.sort_by(|a, b| { - b.metadata - .last_task_run_ts - .cmp(&a.metadata.last_task_run_ts) - .then_with(|| { - a.model() - .string_model - .name - .cmp(&b.model().string_model.name) - }) - }); - envs.first().map(|e| e.id.uid()) -} - -/// Persists the user's environment selection to settings so it can -/// be restored as the default next time. Shared by both the plan -/// card and confirmation card `EnvironmentChanged` handlers. -pub fn persist_environment_selection(environment_id: &str, ctx: &mut ViewContext) { - if environment_id.is_empty() { - return; - } - let all_envs = CloudAmbientAgentEnvironment::get_all(ctx); - if let Some(env) = all_envs.iter().find(|e| e.id.uid() == environment_id) { - let sync_id = env.id; - CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - if let Err(e) = settings - .last_selected_environment_id - .set_value(Some(sync_id), ctx) - { - log::warn!("Failed to persist environment selection: {e:?}"); - } - }); - } -} - // ── Auth secret helpers ──────────────────────────────────────────── -/// Returns `true` when the auth secret picker should be visible: Cloud + -/// non-Oz + a harness with at least one supported auth-secret type. Local -/// non-Oz children inherit auth from the user's shell environment. -pub fn should_show_auth_secret_picker(state: &OrchestrationEditState) -> bool { - if !state.execution_mode.is_remote() { - return false; - } - let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { - return false; - }; - if harness == Harness::Oz { - return false; - } - !auth_secret_types_for_harness(harness).is_empty() -} - -/// Returns the persisted last-selected secret name for this harness, or -/// `None`. Only promotes a persisted name; never auto-picks the first -/// loaded secret. Validates against the loaded secrets list when present, -/// returning `None` if the persisted name has been deleted server-side. -pub fn resolve_default_auth_secret_for_harness( - harness_type: &str, - ctx: &AppContext, -) -> Option { - let harness = Harness::parse_orchestration_harness(harness_type)?; - if harness == Harness::Oz { - return None; - } - let persisted = CloudAgentSettings::as_ref(ctx) - .last_selected_auth_secret - .value() - .get(harness.config_name()) - .cloned() - .filter(|name| !name.trim().is_empty()); - - let availability = HarnessAvailabilityModel::as_ref(ctx); - match availability.auth_secrets_for(harness) { - AuthSecretFetchState::Loaded(secrets) => { - // Drop the persisted name if the secret was deleted server-side. - persisted.filter(|name| secrets.iter().any(|s| s.name == *name)) - } - // Pre-fetch: optimistically show the persisted name; the - // `AuthSecretsLoaded` subscription will re-resolve. - _ => persisted, - } -} - -/// Returns the full persisted selection (Named / Inherit / Unset) for -/// this harness. Prefers an explicit `Inherit` choice over a `Named` -/// fallback so the plan card's "Inherit" survives across the RunAgents -/// handoff (the `OrchestrationConfig` proto doesn't carry auth state). -pub fn resolve_auth_secret_selection_for_harness( - harness_type: &str, - ctx: &AppContext, -) -> AuthSecretSelection { - let Some(harness) = Harness::parse_orchestration_harness(harness_type) else { - return AuthSecretSelection::Unset; - }; - if harness == Harness::Oz { - return AuthSecretSelection::Unset; - } - // Explicit Inherit wins over a stale Named fallback. - let inherit_chosen = CloudAgentSettings::as_ref(ctx) - .inherit_auth_secret_harnesses - .value() - .get(harness.config_name()) - .copied() - .unwrap_or(false); - if inherit_chosen { - return AuthSecretSelection::Inherit; - } - match resolve_default_auth_secret_for_harness(harness_type, ctx) { - Some(name) => AuthSecretSelection::Named(name), - None => AuthSecretSelection::Unset, - } -} - -/// `true` when the user must pick an API key (or Inherit) before Accept is -/// allowed. Fires on `Unset` for any non-Oz cloud harness with managed-secret -/// types, regardless of fetch state — dispatching with an unintended -/// `Inherit` while secrets are still loading would fail downstream. -pub fn auth_secret_selection_required(state: &OrchestrationEditState, _ctx: &AppContext) -> bool { - if !should_show_auth_secret_picker(state) { - return false; - } - if !matches!( - state.auth_secret_selection, - AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew - ) { - return false; - } - let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { - return false; - }; - if harness == Harness::Oz || auth_secret_types_for_harness(harness).is_empty() { - return false; - } - true -} - -/// [`OrchestrationEditState::accept_disabled_reason`] plus the -/// auth-secret-selection gate. Card views should prefer this. -pub fn accept_disabled_reason_with_auth( - state: &OrchestrationEditState, - ctx: &AppContext, -) -> Option { - if let Some(reason) = state.accept_disabled_reason() { - return Some(reason.to_string()); - } - if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { - if let Some(harness) = Harness::parse_local_child_harness(&state.harness_type) { - match local_harness_setup_state(harness) { - LocalHarnessSetupState::MissingHarness { tooltip } => { - return Some(tooltip.to_string()); - } - LocalHarnessSetupState::ProductDisabled { message } => { - return Some(message.to_string()); - } - LocalHarnessSetupState::Ready => {} - } - } - } - if auth_secret_selection_required(state, ctx) { - return Some("Select an API key for this harness to continue.".to_string()); - } - None -} - /// Populates the auth secret picker: Inherit, loaded managed secrets, then /// a "+ New API key…" entry for harnesses with managed-secret types. Also /// kicks off a lazy fetch so subsequent paints replace "Loading…" with @@ -1272,31 +713,10 @@ pub fn populate_auth_secret_picker_for_harness( - state: &mut OrchestrationEditState, - _handles: &OrchestrationPickerHandles, - new_name: Option, - ctx: &mut ViewContext, -) { - let normalized = new_name.filter(|s| !s.trim().is_empty()); - state.auth_secret_selection = match normalized { - Some(name) => AuthSecretSelection::Named(name), - None => AuthSecretSelection::Inherit, - }; - persist_auth_secret_selection(&state.harness_type, &state.auth_secret_selection, ctx); -} - /// Marks `CreatingNew` (not re-seeded from settings, so a background refresh /// can't restore a stale selection mid-create). Used by both card views. pub fn apply_create_new_auth_secret_requested( - state: &mut OrchestrationEditState, + state: &mut OrchestrationConfigState, _ctx: &mut ViewContext, ) { state.select_create_new_auth_secret(); @@ -1305,7 +725,7 @@ pub fn apply_create_new_auth_secret_requested( /// Adopts a freshly-created secret as the active selection when its /// harness matches the card's current harness. Returns `true` on mutation. pub fn apply_created_auth_secret_if_matches( - state: &mut OrchestrationEditState, + state: &mut OrchestrationConfigState, created_harness: Harness, created_name: &str, ctx: &mut ViewContext, @@ -1324,93 +744,38 @@ pub fn apply_created_auth_secret_if_matches( true } -/// Persists the user's auth-secret choice for the active harness. -/// `Named` writes to `last_selected_auth_secret` and clears any prior -/// `Inherit` flag. `Inherit` clears the named entry and sets the inherit -/// flag. `Unset`/`CreatingNew` clear both (no recorded choice). No-op for -/// Oz / unknown. -fn persist_auth_secret_selection( - harness_type: &str, - selection: &AuthSecretSelection, - ctx: &mut ViewContext, -) { - let Some(harness) = Harness::parse_orchestration_harness(harness_type) else { - return; - }; - if harness == Harness::Oz { - return; +// ── Shared action helpers ─────────────────────────────────── + +/// Worker host to display for the current execution mode (Local always +/// shows the Warp host). +fn current_worker_host(state: &OrchestrationConfigState) -> &str { + match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), + RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST, } - let key = harness.config_name().to_string(); - CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - let mut named_map = settings.last_selected_auth_secret.value().clone(); - let mut inherit_map = settings.inherit_auth_secret_harnesses.value().clone(); - match selection { - AuthSecretSelection::Named(name) => { - named_map.insert(key.clone(), name.clone()); - inherit_map.remove(&key); - } - AuthSecretSelection::Inherit => { - named_map.remove(&key); - inherit_map.insert(key, true); - } - AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { - named_map.remove(&key); - inherit_map.remove(&key); - } - } - report_if_error!(settings.last_selected_auth_secret.set_value(named_map, ctx)); - report_if_error!(settings - .inherit_auth_secret_harnesses - .set_value(inherit_map, ctx)); - }); } -// ── Shared action helpers ─────────────────────────────────────────── - -/// Handles a harness change for both card views: saves/restores per-harness -/// model selection, repopulates the model picker, and re-resolves the auth -/// secret selection for the new harness. +/// Handles a harness change for both card views: applies the shared +/// [`OrchestrationEditState::apply_harness_change`] transition, then +/// repopulates the affected pickers. /// -/// Does NOT re-enter the harness picker that dispatched this action. +/// Does NOT re-enter the harness picker that dispatched this action +/// (unless local sanitization changed the harness out from under it). pub fn apply_harness_change( - state: &mut OrchestrationEditState, - memory: &mut HashMap, + orchestration_edit_state: &mut OrchestrationEditState, handles: &OrchestrationPickerHandles, new_harness_type: &str, - fallback_base_model_id: impl FnOnce(&mut ViewContext) -> Option, + fallback_base_model_id: Option, ctx: &mut ViewContext, ) { - // Save current model for the old harness. - let old_key = harness_save_key(&state.harness_type).to_string(); - memory.insert(old_key, state.model_id.clone()); - state.harness_type = new_harness_type.to_string(); - + orchestration_edit_state.apply_harness_change(new_harness_type, fallback_base_model_id, ctx); + let state = &orchestration_edit_state.orchestration_config_state; let is_local = !state.execution_mode.is_remote(); - if is_local { - state.sanitize_for_local_execution(); - if state.harness_type != new_harness_type { - if let Some(handle) = &handles.harness_picker { - populate_harness_picker(handle, &state.harness_type, true, ctx); - } + if is_local && state.harness_type != new_harness_type { + if let Some(handle) = &handles.harness_picker { + populate_harness_picker(handle, &state.harness_type, true, ctx); } } - // Try to restore a previously saved model for this harness. - let new_key = harness_save_key(&state.harness_type); - let restored = memory - .get(new_key) - .filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx)) - .cloned(); - if let Some(saved_id) = restored { - state.model_id = saved_id; - } else if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { - // No saved model — fall back to conversation base model - // for Oz, or default for non-Oz. - let reset_id = fallback_base_model_id(ctx) - .filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx)) - .or_else(|| first_filtered_model_id(&state.harness_type, ctx)) - .unwrap_or_default(); - state.model_id = reset_id; - } if let Some(handle) = &handles.model_picker { populate_model_picker_for_harness( handle, @@ -1420,10 +785,6 @@ pub fn apply_harness_change( ctx, ); } - - // Re-resolve auth selection from per-harness persisted state. - // Honors an explicit `Inherit` choice for the new harness. - state.auth_secret_selection = resolve_auth_secret_selection_for_harness(new_harness_type, ctx); if let Some(handle) = &handles.auth_secret_picker { populate_auth_secret_picker_for_harness( handle, @@ -1434,38 +795,22 @@ pub fn apply_harness_change( } } -/// Handles an execution-mode toggle for both card views: toggles the -/// mode, revalidates/resets the model_id if invalid for the new mode, -/// repopulates the model picker, and syncs all picker selections. +/// Handles an execution-mode toggle for both card views: applies the +/// shared [`OrchestrationConfigState::apply_execution_mode_change`] +/// transition, then repopulates the affected pickers and syncs all +/// picker selections. pub fn apply_execution_mode_change( - state: &mut OrchestrationEditState, + state: &mut OrchestrationConfigState, handles: &OrchestrationPickerHandles, is_remote: bool, - fallback_base_model_id: impl FnOnce(&mut ViewContext) -> Option, + fallback_base_model_id: Option, ctx: &mut ViewContext, ) { - state.toggle_execution_mode_to_remote(is_remote); + state.apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); let is_local = !state.execution_mode.is_remote(); if let Some(handle) = &handles.harness_picker { populate_harness_picker(handle, &state.harness_type, is_local, ctx); } - // Pre-fill environment with the last-selected one when switching to Cloud. - if is_remote { - if let RunAgentsExecutionMode::Remote { environment_id, .. } = &state.execution_mode { - if environment_id.is_empty() { - if let Some(default_env) = resolve_default_environment_id(ctx) { - state.set_environment_id(default_env); - } - } - } - } - if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { - let reset_id = fallback_base_model_id(ctx) - .filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx)) - .or_else(|| first_filtered_model_id(&state.harness_type, ctx)) - .unwrap_or_default(); - state.model_id = reset_id; - } if let Some(handle) = &handles.model_picker { populate_model_picker_for_harness( handle, @@ -1476,39 +821,27 @@ pub fn apply_execution_mode_change( ); } if let Some(handle) = &handles.host_picker { - let initial_host = match &state.execution_mode { - RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), - RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST, - }; - populate_host_picker(handle, initial_host, ctx); + populate_host_picker(handle, current_worker_host(state), ctx); } sync_picker_selections(state, handles, ctx); } // ── Picker repopulation + selection sync ── -/// Repopulates the harness, model, and auth-secret pickers from the -/// current server-provided data, revalidates `state.model_id` against -/// the updated catalog (resetting to default if the model disappeared), -/// then re-syncs dropdown selections. +/// Revalidates the edit state against the latest catalogs via +/// [`OrchestrationConfigState::revalidate_after_catalog_change`], then +/// repopulates every picker from the current server-provided data and +/// re-syncs dropdown selections. pub fn repopulate_all_pickers( - state: &mut OrchestrationEditState, + state: &mut OrchestrationConfigState, handles: &OrchestrationPickerHandles, ctx: &mut ViewContext, ) { + state.revalidate_after_catalog_change(ctx); let is_local = !state.execution_mode.is_remote(); - if is_local { - state.sanitize_for_local_execution(); - } if let Some(handle) = &handles.harness_picker { populate_harness_picker(handle, &state.harness_type, is_local, ctx); } - // Reset model if it disappeared from the harness's catalog. - if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { - if let Some(first_id) = first_filtered_model_id(&state.harness_type, ctx) { - state.model_id = first_id; - } - } if let Some(handle) = &handles.model_picker { populate_model_picker_for_harness( handle, @@ -1518,29 +851,6 @@ pub fn repopulate_all_pickers( ctx, ); } - // Drop any `Named(_)` selection whose secret no longer exists. - if let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) { - if harness != Harness::Oz { - if let AuthSecretFetchState::Loaded(secrets) = - HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness) - { - if let AuthSecretSelection::Named(name) = &state.auth_secret_selection { - if !secrets.iter().any(|s| s.name == *name) { - state.auth_secret_selection = AuthSecretSelection::Unset; - } - } - } - } - } - // Re-seed `Unset` from persisted settings. Leaves `Inherit` alone. - // Uses the full selection resolver so a prior explicit Inherit is - // restored (rather than being downgraded to Unset). - if matches!(state.auth_secret_selection, AuthSecretSelection::Unset) { - let resolved = resolve_auth_secret_selection_for_harness(&state.harness_type, ctx); - if !matches!(resolved, AuthSecretSelection::Unset) { - state.auth_secret_selection = resolved; - } - } if let Some(handle) = &handles.auth_secret_picker { populate_auth_secret_picker_for_harness( handle, @@ -1550,17 +860,13 @@ pub fn repopulate_all_pickers( ); } if let Some(handle) = &handles.host_picker { - let initial_host = match &state.execution_mode { - RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), - RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST, - }; - populate_host_picker(handle, initial_host, ctx); + populate_host_picker(handle, current_worker_host(state), ctx); } sync_picker_selections(state, handles, ctx); } pub fn sync_picker_selections( - state: &OrchestrationEditState, + state: &OrchestrationConfigState, handles: &OrchestrationPickerHandles, ctx: &mut ViewContext, ) { @@ -1598,13 +904,7 @@ pub fn sync_picker_selections( } if let Some(harness_picker) = handles.harness_picker.clone() { let harness_type = state.harness_type.clone(); - let show_harness_picker = should_show_harness_picker(state); harness_picker.update(ctx, |dropdown, ctx_dropdown| { - if show_harness_picker { - dropdown.set_enabled(ctx_dropdown); - } else { - dropdown.set_disabled(ctx_dropdown); - } let target = Harness::parse_orchestration_harness(&harness_type).unwrap_or(Harness::Oz); // Use the server-provided display_name from HarnessAvailabilityModel // so the selection matches the labels (which also use display_name). @@ -1631,10 +931,7 @@ pub fn sync_picker_selections( }); } if let Some(host_picker) = handles.host_picker.clone() { - let worker_host = match &state.execution_mode { - RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.clone(), - RunAgentsExecutionMode::Local => ORCHESTRATION_WARP_WORKER_HOST.to_string(), - }; + let worker_host = current_worker_host(state).to_string(); host_picker.update(ctx, |picker, picker_ctx| { picker.set_selected(&worker_host, picker_ctx); }); @@ -1660,10 +957,6 @@ pub fn sync_picker_selections( } } -#[cfg(test)] -#[path = "orchestration_controls_tests.rs"] -mod tests; - // ── Adaptive picker layout ────────────────────────────────────────── /// Lays out children horizontally at a fixed width when they all fit, @@ -1917,7 +1210,7 @@ fn render_segment_button( } pub fn render_picker_row( - state: &OrchestrationEditState, + state: &OrchestrationConfigState, handles: &OrchestrationPickerHandles, appearance: &Appearance, ) -> Box { @@ -1927,14 +1220,12 @@ pub fn render_picker_row( /// Renders pickers vertically at full width when `vertical` is true, /// or in the original horizontal layout when false. pub fn render_picker_row_with_layout( - state: &OrchestrationEditState, + state: &OrchestrationConfigState, handles: &OrchestrationPickerHandles, appearance: &Appearance, vertical: bool, ) -> Box { let is_remote = state.execution_mode.is_remote(); - let show_harness_picker = should_show_harness_picker(state); - let show_auth_picker = should_show_auth_secret_picker(state); if vertical { @@ -1950,16 +1241,14 @@ pub fn render_picker_row_with_layout( // key) before host/environment/model so the API key sits directly // under the harness selector and does not split the model picker // from the "Primary model…" subtext that follows the picker row. - if show_harness_picker { - add( - &mut column, - "Agent harness", - handles - .harness_picker - .as_ref() - .map(|p| ChildView::new(p).finish()), - ); - } + add( + &mut column, + "Agent harness", + handles + .harness_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); if show_auth_picker { add( &mut column, @@ -2009,16 +1298,14 @@ pub fn render_picker_row_with_layout( row.add_child(col); }; - if show_harness_picker { - add_picker( - &mut row, - "Agent harness", - handles - .harness_picker - .as_ref() - .map(|p| ChildView::new(p).finish()), - ); - } + add_picker( + &mut row, + "Agent harness", + handles + .harness_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); if is_remote { add_picker( &mut row, @@ -2099,29 +1386,3 @@ pub fn render_validation_error( .with_margin_bottom(8.) .finish() } - -pub fn empty_env_recommendation_message( - execution_mode: &RunAgentsExecutionMode, - app: &AppContext, -) -> Option { - let RunAgentsExecutionMode::Remote { - environment_id, - worker_host, - .. - } = execution_mode - else { - return None; - }; - if !environment_id.trim().is_empty() { - return None; - } - if !worker_host.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { - return None; - } - let env_count = CloudAmbientAgentEnvironment::get_all(app).len(); - Some(if env_count > 0 { - "We recommend selecting an environment for cloud agents.".to_string() - } else { - "We recommend creating an environment for cloud agents.".to_string() - }) -} diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs deleted file mode 100644 index 96c47fb96ee..00000000000 --- a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs +++ /dev/null @@ -1,200 +0,0 @@ -use ai::agent::action::RunAgentsExecutionMode; -use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; - -use super::{ - should_show_auth_secret_picker, should_show_harness_picker, AuthSecretSelection, - OrchestrationEditState, -}; - -fn remote_claude_state() -> OrchestrationEditState { - OrchestrationEditState::from_run_agents_fields( - "sonnet", - "claude", - &RunAgentsExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - computer_use_enabled: false, - }, - ) -} - -fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { - OrchestrationConfig { - model_id: model_id.to_string(), - harness_type: harness_type.to_string(), - execution_mode: OrchestrationExecutionMode::Local, - } -} - -#[test] -fn from_orchestration_config_preserves_local_claude() { - let state = - OrchestrationEditState::from_orchestration_config(&local_config("claude", "sonnet")); - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "sonnet"); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn harness_picker_stays_visible_for_local_mode() { - let state = OrchestrationEditState::from_run_agents_fields( - "auto", - "oz", - &RunAgentsExecutionMode::Local, - ); - assert!(should_show_harness_picker(&state)); -} - -#[test] -fn harness_picker_stays_visible_for_remote_mode() { - let state = OrchestrationEditState::from_run_agents_fields( - "auto", - "oz", - &RunAgentsExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - computer_use_enabled: false, - }, - ); - - assert!(should_show_harness_picker(&state)); -} - -#[test] -fn from_orchestration_config_preserves_remote_claude() { - let state = OrchestrationEditState::from_orchestration_config(&OrchestrationConfig { - model_id: "sonnet".to_string(), - harness_type: "claude".to_string(), - execution_mode: OrchestrationExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - }, - }); - - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "sonnet"); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Remote { - ref environment_id, - ref worker_host, - computer_use_enabled: false, - } if environment_id == "env-1" && worker_host == "warp" - )); -} - -#[test] -fn toggle_to_local_sanitizes_disabled_codex() { - let mut state = OrchestrationEditState::from_run_agents_fields( - "gpt-5", - "codex", - &RunAgentsExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - computer_use_enabled: false, - }, - ); - - state.toggle_execution_mode_to_remote(false); - - assert_eq!(state.harness_type, "oz"); - assert_eq!(state.model_id, ""); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn toggle_to_local_preserves_claude() { - let mut state = OrchestrationEditState::from_run_agents_fields( - "sonnet", - "claude", - &RunAgentsExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - computer_use_enabled: false, - }, - ); - - state.toggle_execution_mode_to_remote(false); - - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "sonnet"); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn accept_disabled_reason_allows_local_claude_product() { - let state = OrchestrationEditState::from_run_agents_fields( - "auto", - "claude", - &RunAgentsExecutionMode::Local, - ); - assert_eq!(state.accept_disabled_reason(), None); -} - -#[test] -fn resolve_from_config_preserves_local_claude() { - let mut state = - OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); - - state.resolve_from_config(&local_config("claude", "sonnet")); - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "sonnet"); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn resolve_from_config_sanitizes_disabled_local_codex() { - let mut state = - OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); - - state.resolve_from_config(&local_config("codex", "gpt-5")); - - assert_eq!(state.harness_type, "oz"); - assert_eq!(state.model_id, ""); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn select_create_new_auth_secret_marks_creating_new_from_named() { - let mut state = remote_claude_state(); - state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string()); - assert_eq!(state.auth_secret_name(), Some("my-key")); - - state.select_create_new_auth_secret(); - - // `CreatingNew` (distinct from `Unset`) blocks Accept and isn't re-seeded. - assert!(matches!( - state.auth_secret_selection, - AuthSecretSelection::CreatingNew - )); - assert_eq!(state.auth_secret_name(), None); - assert!(should_show_auth_secret_picker(&state)); -} - -#[test] -fn select_create_new_auth_secret_marks_creating_new_from_inherit() { - let mut state = remote_claude_state(); - state.auth_secret_selection = AuthSecretSelection::Inherit; - - state.select_create_new_auth_secret(); - - assert!(matches!( - state.auth_secret_selection, - AuthSecretSelection::CreatingNew - )); -} diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 1f881671aee..1fc19081268 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -3,7 +3,6 @@ //! Each card is a `View` keyed by `AIAgentActionId`, embedded by //! `AIBlock` via `ChildView`. Keybindings and Accept dispatch live on //! the view; only `RejectRequested` flows back to the parent. -use std::collections::HashMap; use std::rc::Rc; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; @@ -40,7 +39,8 @@ use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEve use crate::ai::blocklist::inline_action::inline_action_header::{HeaderConfig, InteractionMode}; use crate::ai::blocklist::inline_action::inline_action_icons; use crate::ai::blocklist::inline_action::orchestration_controls::{ - self as oc, AuthSecretSelection, OrchestrationControlAction, OrchestrationPickerHandles, + self as oc, AuthSecretSelection, OrchestrationConfigState, OrchestrationControlAction, + OrchestrationEditState, OrchestrationPickerHandles, }; use crate::ai::blocklist::inline_action::requested_action::{ render_requested_action_row_for_text, CTRL_C_KEYSTROKE, ENTER_KEYSTROKE, @@ -94,12 +94,10 @@ pub fn init(app: &mut AppContext) { ]); } -/// Per-action edit state for the orchestrate confirmation card. -/// Delegates run-wide config fields to `oc::OrchestrationEditState` -/// and adds card-specific fields. +/// Card-only request fields; the run-wide config lives on the view's +/// [`OrchestrationEditState`]. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RunAgentsEditState { - pub orch: oc::OrchestrationEditState, +pub struct RunAgentsCardFields { pub agent_run_configs: Vec, pub base_prompt: String, pub summary: String, @@ -109,41 +107,54 @@ pub struct RunAgentsEditState { pub plan_id: String, } +/// Per-action edit state for the orchestrate confirmation card: the +/// run-wide config fields plus the card-only request fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunAgentsEditState { + pub orchestration_config_state: oc::OrchestrationConfigState, + pub card: RunAgentsCardFields, +} + impl RunAgentsEditState { pub fn from_request(req: &RunAgentsRequest) -> Self { - let mut orch = oc::OrchestrationEditState::from_run_agents_fields( + let mut orchestration_config_state = oc::OrchestrationConfigState::from_run_agents_fields( &req.model_id, &req.harness_type, &req.execution_mode, ); // Carry the request's auth secret across the round trip. Absence // becomes `Unset`; the picker re-resolves from persisted settings. - orch.auth_secret_selection = + orchestration_config_state.auth_secret_selection = AuthSecretSelection::from_optional_name(req.harness_auth_secret_name.clone()); if matches!(req.execution_mode, RunAgentsExecutionMode::Local) { - orch.sanitize_for_local_execution(); + orchestration_config_state.sanitize_for_local_execution(); } Self { - orch, - agent_run_configs: req.agent_run_configs.clone(), - base_prompt: req.base_prompt.clone(), - summary: req.summary.clone(), - skills: req.skills.clone(), - plan_id: req.plan_id.clone(), + orchestration_config_state, + card: RunAgentsCardFields { + agent_run_configs: req.agent_run_configs.clone(), + base_prompt: req.base_prompt.clone(), + summary: req.summary.clone(), + skills: req.skills.clone(), + plan_id: req.plan_id.clone(), + }, } } pub fn to_request(&self) -> RunAgentsRequest { RunAgentsRequest { - summary: self.summary.clone(), - base_prompt: self.base_prompt.clone(), - skills: self.skills.clone(), - model_id: self.orch.model_id.clone(), - harness_type: self.orch.harness_type.clone(), - execution_mode: self.orch.execution_mode.clone(), - agent_run_configs: self.agent_run_configs.clone(), - plan_id: self.plan_id.clone(), - harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string), + summary: self.card.summary.clone(), + base_prompt: self.card.base_prompt.clone(), + skills: self.card.skills.clone(), + model_id: self.orchestration_config_state.model_id.clone(), + harness_type: self.orchestration_config_state.harness_type.clone(), + execution_mode: self.orchestration_config_state.execution_mode.clone(), + agent_run_configs: self.card.agent_run_configs.clone(), + plan_id: self.card.plan_id.clone(), + harness_auth_secret_name: self + .orchestration_config_state + .auth_secret_name() + .map(str::to_string), } } } @@ -216,7 +227,10 @@ pub enum RunAgentsCardViewEvent { pub struct RunAgentsCardView { action_id: AIAgentActionId, - state: RunAgentsEditState, + /// Run-wide config being edited plus per-harness model memory. + orchestration_edit_state: OrchestrationEditState, + /// Card-only request fields. + card: RunAgentsCardFields, handles: RunAgentsCardHandles, spawning: Option, /// Retained for interactive defaults and telemetry about plan-sourced @@ -230,9 +244,6 @@ pub struct RunAgentsCardView { action_model: ModelHandle, block_model: Rc>, - /// UI-only per-harness model memory so switching harnesses preserves - /// the user's previous model selection for each harness. - saved_model_per_harness: HashMap, create_environment_modal: ViewHandle, /// Snapshot of the latest raw `RunAgentsRequest` from the LLM /// stream. Used at decision time to diff the run-wide config @@ -256,16 +267,17 @@ pub struct RunAgentsCardView { /// 2. Defaults Remote worker_host to "warp". /// 3. Defaults a Remote environment from settings / recency. fn resolve_interactive_defaults( - state: &mut RunAgentsEditState, + orchestration_config_state: &mut OrchestrationConfigState, block_model: &dyn AIBlockModel, ctx: &AppContext, ) { - if state.orch.model_id.is_empty() { - let harness = - warp_cli::agent::Harness::parse_orchestration_harness(&state.orch.harness_type); + if orchestration_config_state.model_id.is_empty() { + let harness = warp_cli::agent::Harness::parse_orchestration_harness( + &orchestration_config_state.harness_type, + ); if matches!(harness, Some(warp_cli::agent::Harness::Oz) | None) { if let Some(base) = block_model.base_model(ctx).map(|id| id.to_string()) { - state.orch.model_id = base; + orchestration_config_state.model_id = base; } } } @@ -273,7 +285,7 @@ fn resolve_interactive_defaults( environment_id, worker_host, .. - } = &state.orch.execution_mode + } = &orchestration_config_state.execution_mode { let needs_host = worker_host.is_empty(); let needs_env = environment_id.is_empty(); @@ -284,11 +296,11 @@ fn resolve_interactive_defaults( // `HostSelector` initial-selection behavior. let default_host = oc::resolve_default_host_slug(ctx) .unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); - state.orch.set_worker_host(default_host); + orchestration_config_state.set_worker_host(default_host); } if needs_env { if let Some(default_env) = oc::resolve_default_environment_id(ctx) { - state.orch.set_environment_id(default_env); + orchestration_config_state.set_environment_id(default_env); } } } @@ -303,7 +315,10 @@ impl RunAgentsCardView { block_model: Rc>, ctx: &mut ViewContext, ) -> Self { - let state = RunAgentsEditState::from_request(request); + let RunAgentsEditState { + orchestration_config_state, + card, + } = RunAgentsEditState::from_request(request); // Snapshot the raw incoming request so we can diff against the // edited state at Accept time. let original_tool_call_request = request.clone(); @@ -384,8 +399,16 @@ impl RunAgentsCardView { // ready for user confirmation. Re-render so the card // transitions from the "Configuring agents..." placeholder // to the full confirmation UI. - resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx); - oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx); + resolve_interactive_defaults( + &mut me.orchestration_edit_state.orchestration_config_state, + &*me.block_model, + ctx, + ); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); me.refresh_accept_button_state(ctx); me.maybe_auto_open_create_modal(ctx); if let Some(conversation_id) = me.block_model.conversation_id(ctx) { @@ -402,11 +425,19 @@ impl RunAgentsCardView { ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { if let Some(handle) = &me.handles.pickers.model_picker { - let is_local = !me.state.orch.execution_mode.is_remote(); + let is_local = !me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote(); oc::populate_model_picker_for_harness( handle, - &me.state.orch.model_id, - &me.state.orch.harness_type, + &me.orchestration_edit_state + .orchestration_config_state + .model_id, + &me.orchestration_edit_state + .orchestration_config_state + .harness_type, is_local, ctx, ); @@ -424,12 +455,16 @@ impl RunAgentsCardView { HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => { // Adopt the new secret before repopulating the picker. oc::apply_created_auth_secret_if_matches( - &mut me.state.orch, + &mut me.orchestration_edit_state.orchestration_config_state, *harness, name, ctx, ); - oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); me.refresh_accept_button_state(ctx); ctx.notify(); } @@ -440,7 +475,11 @@ impl RunAgentsCardView { // Repopulate even on fetch failure to replace "Loading…". // Deleted events also force a repopulate so this card // stops surfacing the deleted secret as an option. - oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); me.refresh_accept_button_state(ctx); me.maybe_auto_open_create_modal(ctx); ctx.notify(); @@ -464,7 +503,11 @@ impl RunAgentsCardView { &ConnectedSelfHostedWorkersModel::handle(ctx), |me, _, event, ctx| match event { ConnectedSelfHostedWorkersEvent::Changed => { - oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); me.refresh_accept_button_state(ctx); ctx.notify(); } @@ -475,7 +518,8 @@ impl RunAgentsCardView { // hasn't been queued in pending_actions yet at construction time. let mut view = Self { action_id, - state, + orchestration_edit_state: OrchestrationEditState::new(orchestration_config_state), + card, handles: RunAgentsCardHandles { reject_button: Some(reject_button), accept_button: Some(accept_button), @@ -488,7 +532,6 @@ impl RunAgentsCardView { position_id_prefix, action_model, block_model, - saved_model_per_harness: HashMap::new(), create_environment_modal, original_tool_call_request, entered_event_emitted: false, @@ -505,6 +548,18 @@ impl RunAgentsCardView { view } + /// Reassembles the value-type edit state from the run-wide config + /// and card fields (request building and streaming-update comparison). + fn config_state(&self) -> RunAgentsEditState { + RunAgentsEditState { + orchestration_config_state: self + .orchestration_edit_state + .orchestration_config_state + .clone(), + card: self.card.clone(), + } + } + /// Re-sync edit state from the latest streaming request. pub fn update_request(&mut self, request: &RunAgentsRequest, ctx: &mut ViewContext) { if self.spawning.is_some() { @@ -517,37 +572,60 @@ impl RunAgentsCardView { // Resolve empty fields from the active config (same as in new()). if let Some((config, status)) = &self.active_config { if status.is_approved() { - new_state.orch.resolve_from_config(config); + new_state + .orchestration_config_state + .resolve_from_config(config); } } - if new_state.orch.model_id.is_empty() { - let harness = - warp_cli::agent::Harness::parse_orchestration_harness(&new_state.orch.harness_type); + if new_state.orchestration_config_state.model_id.is_empty() { + let harness = warp_cli::agent::Harness::parse_orchestration_harness( + &new_state.orchestration_config_state.harness_type, + ); if matches!(harness, Some(warp_cli::agent::Harness::Oz) | None) { if let Some(base) = self.block_model.base_model(ctx).map(|id| id.to_string()) { - new_state.orch.model_id = base; + new_state.orchestration_config_state.model_id = base; } } } // Re-seed an Unset selection from persisted per-harness settings, // honoring an explicit `Inherit` choice for this harness. if matches!( - new_state.orch.auth_secret_selection, + new_state.orchestration_config_state.auth_secret_selection, AuthSecretSelection::Unset ) { - new_state.orch.auth_secret_selection = - oc::resolve_auth_secret_selection_for_harness(&new_state.orch.harness_type, ctx); + new_state.orchestration_config_state.auth_secret_selection = + oc::resolve_auth_secret_selection_for_harness( + &new_state.orchestration_config_state.harness_type, + ctx, + ); } - if self.state != new_state { - let harness_or_model_changed = self.state.orch.harness_type - != new_state.orch.harness_type - || self.state.orch.model_id != new_state.orch.model_id - || self.state.orch.execution_mode != new_state.orch.execution_mode; - self.state = new_state; + if self.config_state() != new_state { + let harness_or_model_changed = self + .orchestration_edit_state + .orchestration_config_state + .harness_type + != new_state.orchestration_config_state.harness_type + || self + .orchestration_edit_state + .orchestration_config_state + .model_id + != new_state.orchestration_config_state.model_id + || self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + != new_state.orchestration_config_state.execution_mode; + self.orchestration_edit_state.orchestration_config_state = + new_state.orchestration_config_state; + self.card = new_state.card; if harness_or_model_changed { // Repopulate pickers and re-arm auto-open for the newly- // streamed harness. - oc::repopulate_all_pickers(&mut self.state.orch, &self.handles.pickers, ctx); + oc::repopulate_all_pickers( + &mut self.orchestration_edit_state.orchestration_config_state, + &self.handles.pickers, + ctx, + ); self.has_auto_opened_create_modal = false; } self.refresh_accept_button_state(ctx); @@ -565,11 +643,14 @@ impl RunAgentsCardView { if self.spawning.is_some() { return; } - if let Some(reason) = oc::accept_disabled_reason_with_auth(&self.state.orch, ctx) { + if let Some(reason) = oc::accept_disabled_reason_with_auth( + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) { log::warn!("RunAgentsCardView: refusing Accept because action is disabled: {reason}"); return; } - let request = self.state.to_request(); + let request = self.config_state().to_request(); self.emit_decision(RunAgentsCardDecision::Accept, ctx); let action_id = self.action_id.clone(); self.action_model.update(ctx, |action_model, action_ctx| { @@ -591,7 +672,7 @@ impl RunAgentsCardView { send_telemetry_from_ctx!( BlocklistOrchestrationTelemetryEvent::OrchestrationEntered(OrchestrationEnteredEvent { conversation_id, - plan_id: (!self.state.plan_id.is_empty()).then(|| self.state.plan_id.clone()), + plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), entry_source: OrchestrationEntrySource::RunAgentsCardShown, }), ctx @@ -607,8 +688,10 @@ impl RunAgentsCardView { let Some(conversation_id) = self.block_model.conversation_id(ctx) else { return; }; - let modified_fields_from_tool_call = - diverged_orch_fields(&self.state.orch, &self.original_tool_call_request); + let modified_fields_from_tool_call = diverged_orch_fields( + &self.orchestration_edit_state.orchestration_config_state, + &self.original_tool_call_request, + ); let (had_active_config, active_config_status, modified_fields_from_active_config) = match &self.active_config { Some((cfg, status)) => { @@ -620,7 +703,10 @@ impl RunAgentsCardView { None }; let diff = if status.is_approved() { - diverged_orch_fields_against_config(&self.state.orch, cfg) + diverged_orch_fields_against_config( + &self.orchestration_edit_state.orchestration_config_state, + cfg, + ) } else { Vec::new() }; @@ -632,12 +718,20 @@ impl RunAgentsCardView { BlocklistOrchestrationTelemetryEvent::RunAgentsCardDecision( RunAgentsCardDecisionEvent { conversation_id, - plan_id: (!self.state.plan_id.is_empty()).then(|| self.state.plan_id.clone()), + plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), decision, - agent_count: self.state.agent_run_configs.len(), - harness: OrchestrationHarnessKind::from_str(&self.state.orch.harness_type), + agent_count: self.card.agent_run_configs.len(), + harness: OrchestrationHarnessKind::from_str( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type + ), execution_mode: OrchestrationExecutionModeKind::from_run_agents( - &self.state.orch.execution_mode, + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, ), modified_fields_from_tool_call, modified_fields_from_active_config, @@ -673,18 +767,25 @@ impl RunAgentsCardView { ) { return; } - if !oc::should_show_auth_secret_picker(&self.state.orch) { + if !oc::should_show_auth_secret_picker( + &self.orchestration_edit_state.orchestration_config_state, + ) { return; } if !matches!( - self.state.orch.auth_secret_selection, + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection, AuthSecretSelection::Unset ) { return; } - let Some(harness) = - warp_cli::agent::Harness::parse_orchestration_harness(&self.state.orch.harness_type) - else { + let Some(harness) = warp_cli::agent::Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) else { return; }; // Only auto-open on `Loaded([])`. Other fetch states are @@ -703,9 +804,12 @@ impl RunAgentsCardView { } /// Re-derives the Accept button's `disabled` + tooltip from the gate. - /// Call after every code path that mutates `self.state.orch`. + /// Call after every code path that mutates `self.orchestration_edit_state.orchestration_config_state`. fn refresh_accept_button_state(&mut self, ctx: &mut ViewContext) { - let reason = oc::accept_disabled_reason_with_auth(&self.state.orch, ctx); + let reason = oc::accept_disabled_reason_with_auth( + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ); let Some(mut accept) = self.handles.accept_button.clone() else { return; }; @@ -725,21 +829,21 @@ impl RunAgentsCardView { .base_model(ctx) .map(|id| id.to_string()) .unwrap_or_default(); - let state = &self.state; + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.model_picker.is_none() { - let initial_model_id = if state.orch.model_id.trim().is_empty() { + let initial_model_id = if state.model_id.trim().is_empty() { initial_model_id_default.clone() } else { - state.orch.model_id.clone() + state.model_id.clone() }; - let is_local = !state.orch.execution_mode.is_remote(); + let is_local = !state.execution_mode.is_remote(); let handle = oc::new_standard_filterable_picker_dropdown(&styles, ctx); Self::set_upward_filterable_menu_position(&handle, ctx); oc::populate_model_picker_for_harness( &handle, &initial_model_id, - &state.orch.harness_type, + &state.harness_type, is_local, ctx, ); @@ -747,21 +851,23 @@ impl RunAgentsCardView { self.handles.pickers.model_picker = Some(handle); } + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.harness_picker.is_none() { let handle = oc::new_standard_picker_dropdown(&colors, ctx); Self::set_upward_menu_position(&handle, ctx); oc::populate_harness_picker( &handle, - &state.orch.harness_type, - !state.orch.execution_mode.is_remote(), + &state.harness_type, + !state.execution_mode.is_remote(), ctx, ); Self::subscribe_picker_close(&handle, ctx); self.handles.pickers.harness_picker = Some(handle); } + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.environment_picker.is_none() { - let initial_env = match &state.orch.execution_mode { + let initial_env = match &state.execution_mode { RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.as_str(), RunAgentsExecutionMode::Local => "", }; @@ -777,8 +883,9 @@ impl RunAgentsCardView { self.handles.pickers.environment_picker = Some(handle); } + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.host_picker.is_none() { - let initial_host = match &state.orch.execution_mode { + let initial_host = match &state.execution_mode { RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; @@ -817,17 +924,31 @@ impl RunAgentsCardView { // matches what cloud-mode would show. Honors an explicit // `Inherit` choice for this harness. if matches!( - self.state.orch.auth_secret_selection, + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection, AuthSecretSelection::Unset ) { - self.state.orch.auth_secret_selection = - oc::resolve_auth_secret_selection_for_harness( - &self.state.orch.harness_type, - ctx, - ); + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection = oc::resolve_auth_secret_selection_for_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ctx, + ); } - let selection = self.state.orch.auth_secret_selection.clone(); - let harness_type = self.state.orch.harness_type.clone(); + let selection = self + .orchestration_edit_state + .orchestration_config_state + .auth_secret_selection + .clone(); + let harness_type = self + .orchestration_edit_state + .orchestration_config_state + .harness_type + .clone(); let handle = oc::new_standard_picker_dropdown(&colors, ctx); Self::set_upward_menu_position(&handle, ctx); oc::populate_auth_secret_picker_for_harness(&handle, &selection, &harness_type, ctx); @@ -898,7 +1019,11 @@ impl RunAgentsCardView { } fn sync_picker_selections(&mut self, ctx: &mut ViewContext) { - oc::sync_picker_selections(&self.state.orch, &self.handles.pickers, ctx); + oc::sync_picker_selections( + &self.orchestration_edit_state.orchestration_config_state, + &self.handles.pickers, + ctx, + ); } fn open_create_environment_modal(&mut self, ctx: &mut ViewContext) { @@ -912,7 +1037,9 @@ impl RunAgentsCardView { } fn select_created_environment(&mut self, environment_id: String, ctx: &mut ViewContext) { - self.state.orch.set_environment_id(environment_id.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(environment_id.clone()); if let Some(environment_picker) = &self.handles.pickers.environment_picker { oc::populate_environment_picker(environment_picker, &environment_id, ctx); } @@ -974,7 +1101,7 @@ impl View for RunAgentsCardView { } if matches!(status, Some(AIActionStatus::RunningAsync)) { let snapshot = RunAgentsSpawningSnapshot { - agent_count: self.state.agent_run_configs.len(), + agent_count: self.card.agent_run_configs.len(), }; return render_spawning_card(&snapshot, appearance, app); } @@ -1004,7 +1131,13 @@ impl View for RunAgentsCardView { } let is_blocked = matches!(status, Some(AIActionStatus::Blocked)); - let card = render_confirmation_card(&self.state, &self.handles, is_blocked, app); + let card = render_confirmation_card( + &self.orchestration_edit_state.orchestration_config_state, + &self.card, + &self.handles, + is_blocked, + app, + ); let mut root_stack = Stack::new(); root_stack.add_child(card); @@ -1061,12 +1194,12 @@ impl TypedActionView for RunAgentsCardView { ctx.emit(RunAgentsCardViewEvent::RejectRequested); } RunAgentsCardViewAction::ExecutionModeToggled { is_remote } => { - let block_model = self.block_model.clone(); + let fallback = self.block_model.base_model(ctx).map(|id| id.to_string()); oc::apply_execution_mode_change( - &mut self.state.orch, + &mut self.orchestration_edit_state.orchestration_config_state, &self.handles.pickers, *is_remote, - |ctx| block_model.base_model(ctx).map(|id| id.to_string()), + fallback, ctx, ); // Mode change can newly reveal the auth picker (Local @@ -1077,18 +1210,19 @@ impl TypedActionView for RunAgentsCardView { ctx.notify(); } RunAgentsCardViewAction::ModelChanged { model_id } => { - self.state.orch.model_id = model_id.clone(); + self.orchestration_edit_state + .orchestration_config_state + .model_id = model_id.clone(); self.refresh_accept_button_state(ctx); ctx.notify(); } RunAgentsCardViewAction::HarnessChanged { harness_type } => { - let block_model = self.block_model.clone(); + let fallback = self.block_model.base_model(ctx).map(|id| id.to_string()); oc::apply_harness_change( - &mut self.state.orch, - &mut self.saved_model_per_harness, + &mut self.orchestration_edit_state, &self.handles.pickers, harness_type, - |ctx| block_model.base_model(ctx).map(|id| id.to_string()), + fallback, ctx, ); // Harness change resets per-harness selection state, so @@ -1099,7 +1233,9 @@ impl TypedActionView for RunAgentsCardView { ctx.notify(); } RunAgentsCardViewAction::EnvironmentChanged { environment_id } => { - self.state.orch.set_environment_id(environment_id.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(environment_id.clone()); oc::persist_environment_selection(environment_id, ctx); self.refresh_accept_button_state(ctx); ctx.notify(); @@ -1108,25 +1244,30 @@ impl TypedActionView for RunAgentsCardView { self.open_create_environment_modal(ctx); } RunAgentsCardViewAction::WorkerHostChanged { worker_host } => { - self.state.orch.set_worker_host(worker_host.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(worker_host.clone()); oc::persist_host_selection(worker_host, ctx); self.refresh_accept_button_state(ctx); ctx.notify(); } RunAgentsCardViewAction::AuthSecretChanged { auth_secret_name } => { - oc::apply_auth_secret_change( - &mut self.state.orch, - &self.handles.pickers, - auth_secret_name.clone(), - ctx, - ); + self.orchestration_edit_state + .orchestration_config_state + .apply_auth_secret_change(auth_secret_name.clone(), ctx); self.refresh_accept_button_state(ctx); ctx.notify(); } RunAgentsCardViewAction::CreateNewAuthSecretRequested => { - oc::apply_create_new_auth_secret_requested(&mut self.state.orch, ctx); + oc::apply_create_new_auth_secret_requested( + &mut self.orchestration_edit_state.orchestration_config_state, + ctx, + ); if let Some(harness) = warp_cli::agent::Harness::parse_orchestration_harness( - &self.state.orch.harness_type, + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, ) { ctx.dispatch_typed_action( &crate::workspace::WorkspaceAction::OpenCreateAuthSecretModal { harness }, @@ -1143,7 +1284,7 @@ impl TypedActionView for RunAgentsCardView { /// between the user-edited `state` and the LLM's original /// `RunAgentsRequest`. fn diverged_orch_fields( - state: &oc::OrchestrationEditState, + state: &oc::OrchestrationConfigState, original: &RunAgentsRequest, ) -> Vec<&'static str> { let mut fields = Vec::new(); @@ -1187,7 +1328,7 @@ fn diverged_orch_fields( /// approved `OrchestrationConfig`. auth_secret is omitted: managed /// secrets are per-user, not stored on the config. fn diverged_orch_fields_against_config( - state: &oc::OrchestrationEditState, + state: &oc::OrchestrationConfigState, config: &OrchestrationConfig, ) -> Vec<&'static str> { use ai::agent::orchestration_config::OrchestrationExecutionMode; @@ -1228,7 +1369,8 @@ fn diverged_orch_fields_against_config( } fn render_confirmation_card( - state: &RunAgentsEditState, + orchestration_config_state: &OrchestrationConfigState, + card: &RunAgentsCardFields, handles: &RunAgentsCardHandles, is_blocked: bool, app: &AppContext, @@ -1237,14 +1379,14 @@ fn render_confirmation_card( let theme = appearance.theme(); let header = render_header(handles, app); - let body = render_body(state, app); + let body = render_body(card, app); let mut content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(header) .with_child(body); - content.add_child(render_editor(state, handles, app)); + content.add_child(render_editor(orchestration_config_state, handles, app)); let border_color = if is_blocked { theme.accent() @@ -1281,13 +1423,13 @@ fn render_header(handles: &RunAgentsCardHandles, app: &AppContext) -> Box Box { +fn render_body(card: &RunAgentsCardFields, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - column.add_child(render_summary(state, appearance)); - column.add_child(render_agents_section(state, app)); + column.add_child(render_summary(card, appearance)); + column.add_child(render_agents_section(card, app)); Container::new(column.finish()) .with_horizontal_padding(16.) @@ -1297,15 +1439,15 @@ fn render_body(state: &RunAgentsEditState, app: &AppContext) -> Box .finish() } -fn render_summary(state: &RunAgentsEditState, appearance: &Appearance) -> Box { +fn render_summary(card: &RunAgentsCardFields, appearance: &Appearance) -> Box { let theme = appearance.theme(); - let summary = if state.summary.trim().is_empty() { + let summary = if card.summary.trim().is_empty() { format!( "Spawn {} agent(s) to address this task.", - state.agent_run_configs.len() + card.agent_run_configs.len() ) } else { - state.summary.clone() + card.summary.clone() }; let summary_text = Text::new( summary, @@ -1321,11 +1463,11 @@ fn render_summary(state: &RunAgentsEditState, appearance: &Appearance) -> Box Box { +fn render_agents_section(card: &RunAgentsCardFields, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let label = Text::new( - format!("Agents ({})", state.agent_run_configs.len()), + format!("Agents ({})", card.agent_run_configs.len()), appearance.ui_font_family(), appearance.monospace_font_size() - 1., ) @@ -1337,8 +1479,7 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box Box { @@ -1491,7 +1632,7 @@ fn render_editor( column.add_child( Container::new(oc::render_mode_toggle( - state.orch.execution_mode.is_remote(), + orchestration_config_state.execution_mode.is_remote(), &handles.pickers, appearance, None, @@ -1501,19 +1642,19 @@ fn render_editor( .finish(), ); column.add_child(oc::render_picker_row( - &state.orch, + orchestration_config_state, &handles.pickers, appearance, )); - if let Some(reason) = oc::accept_disabled_reason_with_auth(&state.orch, app) { + if let Some(reason) = oc::accept_disabled_reason_with_auth(orchestration_config_state, app) { column.add_child(oc::render_validation_error( reason, theme.ui_error_color(), appearance, )); } else if let Some(message) = - oc::empty_env_recommendation_message(&state.orch.execution_mode, app) + oc::empty_env_recommendation_message(&orchestration_config_state.execution_mode, app) { column.add_child(oc::render_validation_error( message, diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index 54fd30d482b..1eec292747e 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -8,8 +8,8 @@ use ai::agent::action_result::{ use ai::skills::SkillReference; use warp_util::local_or_remote_path::LocalOrRemotePath; -use super::RunAgentsEditState; -use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState; +use super::{RunAgentsCardFields, RunAgentsEditState}; +use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationConfigState; fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest { make_request_with_skills(harness, mode, Vec::new()) @@ -37,22 +37,24 @@ fn make_request_with_skills( } } -fn make_edit_state_with_orch_fields( +fn make_config_state_with_orch_fields( harness: &str, mode: RunAgentsExecutionMode, ) -> RunAgentsEditState { let request = make_request(harness, mode); RunAgentsEditState { - orch: OrchestrationEditState::from_run_agents_fields( + orchestration_config_state: OrchestrationConfigState::from_run_agents_fields( &request.model_id, &request.harness_type, &request.execution_mode, ), - agent_run_configs: request.agent_run_configs, - base_prompt: request.base_prompt, - summary: request.summary, - skills: request.skills, - plan_id: request.plan_id, + card: RunAgentsCardFields { + agent_run_configs: request.agent_run_configs, + base_prompt: request.base_prompt, + summary: request.summary, + skills: request.skills, + plan_id: request.plan_id, + }, } } @@ -61,16 +63,18 @@ fn local_to_cloud_initializes_remote_with_empty_environment() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); assert!(matches!( - state.orch.execution_mode, + state.orchestration_config_state.execution_mode, RunAgentsExecutionMode::Local )); - state.orch.toggle_execution_mode_to_remote(true); + state + .orchestration_config_state + .toggle_execution_mode_to_remote(true); let RunAgentsExecutionMode::Remote { environment_id, worker_host, computer_use_enabled, - } = state.orch.execution_mode + } = state.orchestration_config_state.execution_mode else { panic!("expected Remote after toggle"); }; @@ -89,9 +93,11 @@ fn cloud_to_local_drops_environment() { computer_use_enabled: false, }, )); - state.orch.toggle_execution_mode_to_remote(false); + state + .orchestration_config_state + .toggle_execution_mode_to_remote(false); assert!(matches!( - state.orch.execution_mode, + state.orchestration_config_state.execution_mode, RunAgentsExecutionMode::Local )); } @@ -100,8 +106,10 @@ fn cloud_to_local_drops_environment() { fn local_to_cloud_resets_opencode_to_oz() { let mut state = RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local)); - state.orch.toggle_execution_mode_to_remote(true); - assert_eq!(state.orch.harness_type, "oz"); + state + .orchestration_config_state + .toggle_execution_mode_to_remote(true); + assert_eq!(state.orchestration_config_state.harness_type, "oz"); } #[test] @@ -115,7 +123,10 @@ fn cloud_without_env_no_longer_disables_accept() { }, )); assert!( - state.orch.accept_disabled_reason().is_none(), + state + .orchestration_config_state + .accept_disabled_reason() + .is_none(), "Cloud without env should NOT disable Accept (soft recommendation only)" ); } @@ -131,7 +142,7 @@ fn cloud_with_opencode_disables_accept() { computer_use_enabled: false, }, )); - let reason = state.orch.accept_disabled_reason(); + let reason = state.orchestration_config_state.accept_disabled_reason(); assert!(reason.is_some(), "Cloud + OpenCode should disable Accept"); assert!(reason.unwrap().contains("OpenCode")); } @@ -142,7 +153,10 @@ fn local_with_any_harness_does_not_disable_accept() { let state = RunAgentsEditState::from_request(&make_request(harness, RunAgentsExecutionMode::Local)); assert!( - state.orch.accept_disabled_reason().is_none(), + state + .orchestration_config_state + .accept_disabled_reason() + .is_none(), "Local + {harness} should allow Accept" ); } @@ -150,9 +164,9 @@ fn local_with_any_harness_does_not_disable_accept() { #[test] fn local_with_disabled_codex_disables_accept() { - let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local); + let state = make_config_state_with_orch_fields("codex", RunAgentsExecutionMode::Local); assert_eq!( - state.orch.accept_disabled_reason(), + state.orchestration_config_state.accept_disabled_reason(), Some("Local Codex child agents are temporarily disabled.") ); } @@ -162,9 +176,12 @@ fn from_request_sanitizes_disabled_local_harness_to_oz() { let state = RunAgentsEditState::from_request(&make_request("codex", RunAgentsExecutionMode::Local)); - assert_eq!(state.orch.harness_type, "oz"); - assert_eq!(state.orch.model_id, ""); - assert!(state.orch.accept_disabled_reason().is_none()); + assert_eq!(state.orchestration_config_state.harness_type, "oz"); + assert_eq!(state.orchestration_config_state.model_id, ""); + assert!(state + .orchestration_config_state + .accept_disabled_reason() + .is_none()); } #[test] @@ -179,7 +196,10 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() { }, )); assert!( - state.orch.accept_disabled_reason().is_none(), + state + .orchestration_config_state + .accept_disabled_reason() + .is_none(), "Cloud + env + {harness} should allow Accept" ); } @@ -189,9 +209,11 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() { fn set_environment_id_no_op_in_local_mode() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); - state.orch.set_environment_id("env-1".to_string()); + state + .orchestration_config_state + .set_environment_id("env-1".to_string()); assert!(matches!( - state.orch.execution_mode, + state.orchestration_config_state.execution_mode, RunAgentsExecutionMode::Local )); } @@ -206,8 +228,12 @@ fn set_environment_id_updates_remote() { computer_use_enabled: false, }, )); - state.orch.set_environment_id("new-env".to_string()); - let RunAgentsExecutionMode::Remote { environment_id, .. } = state.orch.execution_mode else { + state + .orchestration_config_state + .set_environment_id("new-env".to_string()); + let RunAgentsExecutionMode::Remote { environment_id, .. } = + state.orchestration_config_state.execution_mode + else { panic!("expected Remote"); }; assert_eq!(environment_id, "new-env"); @@ -401,14 +427,14 @@ mod override_from_approved_config_tests { fn overrides_model_and_harness_unconditionally() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); - assert_eq!(state.orch.model_id, "auto"); - assert_eq!(state.orch.harness_type, "oz"); + assert_eq!(state.orchestration_config_state.model_id, "auto"); + assert_eq!(state.orchestration_config_state.harness_type, "oz"); state - .orch + .orchestration_config_state .override_from_approved_config(&local_config("claude-4-opus", "claude")); - assert_eq!(state.orch.model_id, "claude-4-opus"); - assert_eq!(state.orch.harness_type, "claude"); + assert_eq!(state.orchestration_config_state.model_id, "claude-4-opus"); + assert_eq!(state.orchestration_config_state.harness_type, "claude"); } #[test] @@ -418,10 +444,10 @@ mod override_from_approved_config_tests { RunAgentsExecutionMode::Local, )); state - .orch + .orchestration_config_state .override_from_approved_config(&local_config("gpt-5", "codex")); - assert_eq!(state.orch.model_id, "gpt-5"); - assert_eq!(state.orch.harness_type, "codex"); + assert_eq!(state.orchestration_config_state.model_id, "gpt-5"); + assert_eq!(state.orchestration_config_state.harness_type, "codex"); } #[test] @@ -429,13 +455,13 @@ mod override_from_approved_config_tests { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state - .orch + .orchestration_config_state .override_from_approved_config(&remote_config("auto", "oz", "env-1")); let RunAgentsExecutionMode::Remote { environment_id, worker_host, .. - } = &state.orch.execution_mode + } = &state.orchestration_config_state.execution_mode else { panic!("expected Remote after override"); }; @@ -454,10 +480,13 @@ mod override_from_approved_config_tests { }, )); state - .orch + .orchestration_config_state .override_from_approved_config(&local_config("auto", "oz")); assert!( - matches!(state.orch.execution_mode, RunAgentsExecutionMode::Local), + matches!( + state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + ), "should be Local after override" ); } @@ -473,13 +502,13 @@ mod override_from_approved_config_tests { }, )); state - .orch + .orchestration_config_state .override_from_approved_config(&remote_config("auto", "oz", "new-env")); let RunAgentsExecutionMode::Remote { environment_id, computer_use_enabled, .. - } = &state.orch.execution_mode + } = &state.orchestration_config_state.execution_mode else { panic!("expected Remote"); }; @@ -495,12 +524,12 @@ mod override_from_approved_config_tests { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state - .orch + .orchestration_config_state .override_from_approved_config(&remote_config("auto", "oz", "env-1")); let RunAgentsExecutionMode::Remote { computer_use_enabled, .. - } = &state.orch.execution_mode + } = &state.orchestration_config_state.execution_mode else { panic!("expected Remote"); }; @@ -515,10 +544,10 @@ mod override_from_approved_config_tests { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state - .orch + .orchestration_config_state .override_from_approved_config(&local_config("auto", "codex")); assert_eq!( - state.orch.accept_disabled_reason(), + state.orchestration_config_state.accept_disabled_reason(), Some("Local Codex child agents are temporarily disabled.") ); } @@ -534,12 +563,14 @@ fn local_to_cloud_idempotent_when_already_remote() { computer_use_enabled: true, }, )); - state.orch.toggle_execution_mode_to_remote(true); + state + .orchestration_config_state + .toggle_execution_mode_to_remote(true); let RunAgentsExecutionMode::Remote { environment_id, computer_use_enabled, .. - } = state.orch.execution_mode + } = state.orchestration_config_state.execution_mode else { panic!("expected Remote"); }; diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 923249443b6..a78b4ec2601 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -2,8 +2,6 @@ //! an active `OrchestrationConfigSnapshot`. Shows a "Use orchestration" //! toggle, Cloud/Local picker, and run-wide config dropdowns. -use std::collections::HashMap; - use ai::agent::action::RunAgentsExecutionMode; use ai::agent::orchestration_config::OrchestrationConfigStatus; use pathfinder_geometry::vector::vec2f; @@ -28,8 +26,8 @@ use crate::ai::blocklist::inline_action::create_environment_modal::{ }; use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEvent}; use crate::ai::blocklist::inline_action::orchestration_controls::{ - self as oc, AuthSecretSelection, OrchestrationControlAction, OrchestrationEditState, - OrchestrationPickerHandles, + self as oc, AuthSecretSelection, OrchestrationConfigState, OrchestrationControlAction, + OrchestrationEditState, OrchestrationPickerHandles, }; use crate::ai::blocklist::telemetry::{ AgentProposedConfigEvent, BlocklistOrchestrationTelemetryEvent, OrchestrationApprovalStatus, @@ -128,7 +126,8 @@ impl OrchestrationControlAction for OrchestrationConfigBlockAction { pub struct OrchestrationConfigBlockView { conversation_id: AIConversationId, plan_id: String, - edit_state: OrchestrationEditState, + /// Run-wide config being edited plus per-harness model memory. + orchestration_edit_state: OrchestrationEditState, pickers: OrchestrationPickerHandles, create_environment_modal: ViewHandle, is_approved: bool, @@ -136,9 +135,6 @@ pub struct OrchestrationConfigBlockView { pickers_initialized: bool, toggle_switch_state: SwitchStateHandle, details_mouse_state: MouseStateHandle, - /// UI-only per-harness model memory so switching harnesses preserves - /// the user's previous model selection for each harness. - saved_model_per_harness: HashMap, /// Suppresses self-triggered refresh when `apply_field_change` /// saves the config and the resulting event re-enters /// `refresh_from_model`. @@ -165,20 +161,20 @@ impl OrchestrationConfigBlockView { .conversation(&conversation_id) .and_then(|conv| conv.orchestration_config_for_plan(&plan_id)) .is_some(); - let (edit_state, is_approved) = history + let (config_state, is_approved) = history .conversation(&conversation_id) .and_then(|conv| { conv.orchestration_config_for_plan(&plan_id) .map(|(config, status)| { ( - OrchestrationEditState::from_orchestration_config(config), + OrchestrationConfigState::from_orchestration_config(config), status.is_approved(), ) }) }) .unwrap_or_else(|| { ( - OrchestrationEditState::from_run_agents_fields( + OrchestrationConfigState::from_run_agents_fields( "auto", "oz", &RunAgentsExecutionMode::Local, @@ -208,11 +204,19 @@ impl OrchestrationConfigBlockView { ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { if let Some(handle) = &me.pickers.model_picker { - let is_local = !me.edit_state.execution_mode.is_remote(); + let is_local = !me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote(); oc::populate_model_picker_for_harness( handle, - &me.edit_state.model_id, - &me.edit_state.harness_type, + &me.orchestration_edit_state + .orchestration_config_state + .model_id, + &me.orchestration_edit_state + .orchestration_config_state + .harness_type, is_local, ctx, ); @@ -230,12 +234,16 @@ impl OrchestrationConfigBlockView { HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => { if me.pickers_initialized { oc::apply_created_auth_secret_if_matches( - &mut me.edit_state, + &mut me.orchestration_edit_state.orchestration_config_state, *harness, name, ctx, ); - oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); } ctx.notify(); } @@ -248,7 +256,11 @@ impl OrchestrationConfigBlockView { // already-mounted picker drops the deleted entry from // its menu. if me.pickers_initialized { - oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); } me.maybe_auto_open_create_modal(ctx); ctx.notify(); @@ -273,7 +285,11 @@ impl OrchestrationConfigBlockView { |me, _, event, ctx| match event { ConnectedSelfHostedWorkersEvent::Changed => { if me.pickers_initialized { - oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); } ctx.notify(); } @@ -282,7 +298,7 @@ impl OrchestrationConfigBlockView { let mut view = Self { conversation_id, plan_id, - edit_state, + orchestration_edit_state: OrchestrationEditState::new(config_state), pickers: OrchestrationPickerHandles::default(), create_environment_modal, is_approved, @@ -290,7 +306,6 @@ impl OrchestrationConfigBlockView { pickers_initialized: false, toggle_switch_state: SwitchStateHandle::default(), details_mouse_state: MouseStateHandle::default(), - saved_model_per_harness: HashMap::new(), suppress_refresh: false, has_auto_opened_create_modal: false, user_has_interacted: false, @@ -333,17 +348,25 @@ impl OrchestrationConfigBlockView { if !self.is_approved || !self.pickers_initialized { return; } - if !oc::should_show_auth_secret_picker(&self.edit_state) { + if !oc::should_show_auth_secret_picker( + &self.orchestration_edit_state.orchestration_config_state, + ) { return; } if !matches!( - self.edit_state.auth_secret_selection, + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection, AuthSecretSelection::Unset ) { return; } - let Some(harness) = Harness::parse_orchestration_harness(&self.edit_state.harness_type) - else { + let Some(harness) = Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) else { return; }; // Only auto-open on `Loaded([])`. Other fetch states are @@ -372,10 +395,15 @@ impl OrchestrationConfigBlockView { let history = BlocklistAIHistoryModel::as_ref(ctx); if let Some(conv) = history.conversation(&self.conversation_id) { if let Some((config, status)) = conv.orchestration_config_for_plan(&self.plan_id) { - self.edit_state = OrchestrationEditState::from_orchestration_config(config); + self.orchestration_edit_state.orchestration_config_state = + OrchestrationConfigState::from_orchestration_config(config); self.is_approved = status.is_approved(); if self.pickers_initialized { - oc::repopulate_all_pickers(&mut self.edit_state, &self.pickers, ctx); + oc::repopulate_all_pickers( + &mut self.orchestration_edit_state.orchestration_config_state, + &self.pickers, + ctx, + ); } ctx.notify(); } @@ -392,22 +420,38 @@ impl OrchestrationConfigBlockView { // When the agent didn't specify a model, fall back to the // conversation's current base model so the picker isn't blank. - let display_model_id = if self.edit_state.model_id.trim().is_empty() { + let display_model_id = if self + .orchestration_edit_state + .orchestration_config_state + .model_id + .trim() + .is_empty() + { BlocklistAIHistoryModel::as_ref(ctx) .conversation(&self.conversation_id) .and_then(|conv| conv.latest_exchange()) .map(|ex| ex.model_id.to_string()) .unwrap_or_default() } else { - self.edit_state.model_id.clone() + self.orchestration_edit_state + .orchestration_config_state + .model_id + .clone() }; - let is_local = !self.edit_state.execution_mode.is_remote(); + let is_local = !self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote(); let model_handle = oc::new_standard_filterable_picker_dropdown(&styles, ctx); model_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); oc::populate_model_picker_for_harness( &model_handle, &display_model_id, - &self.edit_state.harness_type, + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, is_local, ctx, ); @@ -417,7 +461,10 @@ impl OrchestrationConfigBlockView { harness_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); oc::populate_harness_picker( &harness_handle, - &self.edit_state.harness_type, + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, is_local, ctx, ); @@ -427,7 +474,11 @@ impl OrchestrationConfigBlockView { // environment, fill defaults so the pickers aren't blank. // If the config is approved, persist the defaults so the // stored config used by auto-launch has concrete values. - let (needs_host, needs_env) = match &self.edit_state.execution_mode { + let (needs_host, needs_env) = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { RunAgentsExecutionMode::Remote { worker_host, environment_id, @@ -443,19 +494,27 @@ impl OrchestrationConfigBlockView { // `HostSelector` initial-selection behavior. let default_host = oc::resolve_default_host_slug(ctx) .unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string()); - self.edit_state.set_worker_host(default_host); + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(default_host); filled_defaults = true; } if needs_env { if let Some(default_env) = oc::resolve_default_environment_id(ctx) { - self.edit_state.set_environment_id(default_env); + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(default_env); filled_defaults = true; } } if filled_defaults && self.is_approved { self.apply_field_change(ctx); } - let initial_env = match &self.edit_state.execution_mode { + let initial_env = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.as_str(), RunAgentsExecutionMode::Local => "", }; @@ -463,7 +522,11 @@ impl OrchestrationConfigBlockView { env_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); self.pickers.environment_picker = Some(env_handle); - let initial_host = match &self.edit_state.execution_mode { + let initial_host = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(), RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST, }; @@ -494,24 +557,43 @@ impl OrchestrationConfigBlockView { // The full selection resolver honors an explicit `Inherit` choice // (which isn't carried on the OrchestrationConfig wire payload). if matches!( - self.edit_state.auth_secret_selection, + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection, AuthSecretSelection::Unset ) { - self.edit_state.auth_secret_selection = - oc::resolve_auth_secret_selection_for_harness(&self.edit_state.harness_type, ctx); + self.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection = oc::resolve_auth_secret_selection_for_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ctx, + ); } let auth_secret_handle = oc::new_standard_picker_dropdown(&colors, ctx); auth_secret_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); oc::populate_auth_secret_picker_for_harness( &auth_secret_handle, - &self.edit_state.auth_secret_selection, - &self.edit_state.harness_type, + &self + .orchestration_edit_state + .orchestration_config_state + .auth_secret_selection, + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, ctx, ); self.pickers.auth_secret_picker = Some(auth_secret_handle); self.pickers_initialized = true; - oc::sync_picker_selections(&self.edit_state, &self.pickers, ctx); + oc::sync_picker_selections( + &self.orchestration_edit_state.orchestration_config_state, + &self.pickers, + ctx, + ); } fn open_create_environment_modal(&mut self, ctx: &mut ViewContext) { @@ -525,7 +607,9 @@ impl OrchestrationConfigBlockView { } fn select_created_environment(&mut self, environment_id: String, ctx: &mut ViewContext) { - self.edit_state.set_environment_id(environment_id.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(environment_id.clone()); if let Some(environment_picker) = &self.pickers.environment_picker { oc::populate_environment_picker(environment_picker, &environment_id, ctx); } @@ -535,7 +619,10 @@ impl OrchestrationConfigBlockView { fn apply_field_change(&mut self, ctx: &mut ViewContext) { self.suppress_refresh = true; - let config = self.edit_state.to_orchestration_config(); + let config = self + .orchestration_edit_state + .orchestration_config_state + .to_orchestration_config(); let status = if self.is_approved { OrchestrationConfigStatus::Approved } else { @@ -663,7 +750,10 @@ impl View for OrchestrationConfigBlockView { warp_core::ui::theme::color::internal_colors::accent_overlay_2(theme); column.add_child( Container::new(oc::render_mode_toggle( - self.edit_state.execution_mode.is_remote(), + self.orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote(), &self.pickers, appearance, Some(active_seg_bg), @@ -675,7 +765,7 @@ impl View for OrchestrationConfigBlockView { // Pickers stacked vertically column.add_child(oc::render_picker_row_with_layout( - &self.edit_state, + &self.orchestration_edit_state.orchestration_config_state, &self.pickers, appearance, true, @@ -692,15 +782,22 @@ impl View for OrchestrationConfigBlockView { column.add_child(Container::new(helper).with_margin_top(4.).finish()); // Validation - if let Some(reason) = oc::accept_disabled_reason_with_auth(&self.edit_state, app) { + if let Some(reason) = oc::accept_disabled_reason_with_auth( + &self.orchestration_edit_state.orchestration_config_state, + app, + ) { column.add_child(oc::render_validation_error( reason, theme.ui_error_color(), appearance, )); - } else if let Some(message) = - oc::empty_env_recommendation_message(&self.edit_state.execution_mode, app) - { + } else if let Some(message) = oc::empty_env_recommendation_message( + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, + app, + ) { column.add_child(oc::render_validation_error( message, theme.ui_warning_color(), @@ -766,17 +863,15 @@ impl TypedActionView for OrchestrationConfigBlockView { ctx.notify(); } OrchestrationConfigBlockAction::ExecutionModeToggled { is_remote } => { - let conversation_id = self.conversation_id; + let fallback = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&self.conversation_id) + .and_then(|conv| conv.latest_exchange()) + .map(|ex| ex.model_id.to_string()); oc::apply_execution_mode_change( - &mut self.edit_state, + &mut self.orchestration_edit_state.orchestration_config_state, &self.pickers, *is_remote, - |ctx| { - BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&conversation_id) - .and_then(|conv| conv.latest_exchange()) - .map(|ex| ex.model_id.to_string()) - }, + fallback, ctx, ); self.apply_field_change(ctx); @@ -787,23 +882,22 @@ impl TypedActionView for OrchestrationConfigBlockView { ctx.notify(); } OrchestrationConfigBlockAction::ModelChanged { model_id } => { - self.edit_state.model_id = model_id.clone(); + self.orchestration_edit_state + .orchestration_config_state + .model_id = model_id.clone(); self.apply_field_change(ctx); ctx.notify(); } OrchestrationConfigBlockAction::HarnessChanged { harness_type } => { - let conversation_id = self.conversation_id; + let fallback = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&self.conversation_id) + .and_then(|conv| conv.latest_exchange()) + .map(|ex| ex.model_id.to_string()); oc::apply_harness_change( - &mut self.edit_state, - &mut self.saved_model_per_harness, + &mut self.orchestration_edit_state, &self.pickers, harness_type, - |ctx| { - BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&conversation_id) - .and_then(|conv| conv.latest_exchange()) - .map(|ex| ex.model_id.to_string()) - }, + fallback, ctx, ); self.apply_field_change(ctx); @@ -814,7 +908,9 @@ impl TypedActionView for OrchestrationConfigBlockView { ctx.notify(); } OrchestrationConfigBlockAction::EnvironmentChanged { environment_id } => { - self.edit_state.set_environment_id(environment_id.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(environment_id.clone()); oc::persist_environment_selection(environment_id, ctx); self.apply_field_change(ctx); ctx.notify(); @@ -823,7 +919,9 @@ impl TypedActionView for OrchestrationConfigBlockView { self.open_create_environment_modal(ctx); } OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => { - self.edit_state.set_worker_host(worker_host.clone()); + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(worker_host.clone()); oc::persist_host_selection(worker_host, ctx); self.apply_field_change(ctx); ctx.notify(); @@ -831,19 +929,22 @@ impl TypedActionView for OrchestrationConfigBlockView { OrchestrationConfigBlockAction::AuthSecretChanged { auth_secret_name } => { // No `apply_field_change`: secrets are user-scoped and // persisted side-channel, not baked into `OrchestrationConfig`. - oc::apply_auth_secret_change( - &mut self.edit_state, - &self.pickers, - auth_secret_name.clone(), - ctx, - ); + self.orchestration_edit_state + .orchestration_config_state + .apply_auth_secret_change(auth_secret_name.clone(), ctx); ctx.notify(); } OrchestrationConfigBlockAction::CreateNewAuthSecretRequested => { - oc::apply_create_new_auth_secret_requested(&mut self.edit_state, ctx); - if let Some(harness) = - Harness::parse_orchestration_harness(&self.edit_state.harness_type) - { + oc::apply_create_new_auth_secret_requested( + &mut self.orchestration_edit_state.orchestration_config_state, + ctx, + ); + if let Some(harness) = Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) { ctx.dispatch_typed_action(&WorkspaceAction::OpenCreateAuthSecretModal { harness, }); @@ -867,13 +968,40 @@ impl OrchestrationConfigBlockView { plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), status, execution_mode: OrchestrationExecutionModeKind::from_run_agents( - &self.edit_state.execution_mode, + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, ), - harness: OrchestrationHarnessKind::from_str(&self.edit_state.harness_type), - has_model: !self.edit_state.model_id.trim().is_empty(), - has_environment: env_presence(&self.edit_state.execution_mode), - has_worker_host: host_presence(&self.edit_state.execution_mode), - has_auth_secret: self.edit_state.auth_secret_name().is_some(), + harness: OrchestrationHarnessKind::from_str( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type + ), + has_model: !self + .orchestration_edit_state + .orchestration_config_state + .model_id + .trim() + .is_empty(), + has_environment: env_presence( + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + ), + has_worker_host: host_presence( + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + ), + has_auth_secret: self + .orchestration_edit_state + .orchestration_config_state + .auth_secret_name() + .is_some(), } ), ctx @@ -885,13 +1013,36 @@ impl OrchestrationConfigBlockView { BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent { conversation_id: self.conversation_id, plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), - harness: OrchestrationHarnessKind::from_str(&self.edit_state.harness_type), + harness: OrchestrationHarnessKind::from_str( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type + ), execution_mode: OrchestrationExecutionModeKind::from_run_agents( - &self.edit_state.execution_mode, + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, + ), + has_model: !self + .orchestration_edit_state + .orchestration_config_state + .model_id + .trim() + .is_empty(), + has_environment: env_presence( + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + ), + has_worker_host: host_presence( + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode ), - has_model: !self.edit_state.model_id.trim().is_empty(), - has_environment: env_presence(&self.edit_state.execution_mode), - has_worker_host: host_presence(&self.edit_state.execution_mode), }), ctx ); diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 5ed9703a446..680f77d68d8 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -42,6 +42,7 @@ pub(crate) mod llms; pub(crate) mod local_harness_setup; pub(crate) mod metadata_project_rules; pub mod onboarding; +pub(crate) mod orchestration; pub(crate) mod persisted_workspace; pub(crate) mod predict; #[cfg(all(not(target_family = "wasm"), feature = "local_fs"))] diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs new file mode 100644 index 00000000000..8b98cf71f7b --- /dev/null +++ b/app/src/ai/orchestration/config_state.rs @@ -0,0 +1,259 @@ +//! Run-wide orchestration edit state shared by the GUI confirmation +//! card / plan-card config block and the TUI orchestration card. + +use ai::agent::action::RunAgentsExecutionMode; +use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; +use warp_cli::agent::Harness; + +use super::providers::ORCHESTRATION_WARP_WORKER_HOST; +use super::validation::should_show_auth_secret_picker; +use crate::ai::local_harness_setup::local_harness_product_disabled_message; + +/// The user's current selection in the auth secret picker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthSecretSelection { + /// No choice yet; re-seeded from persisted settings. Blocks Accept. + Unset, + /// User explicitly chose to inherit credentials from the worker env. + Inherit, + /// User picked a managed secret by name. + Named(String), + /// Creating a key (modal open). Blocks Accept and, unlike `Unset`, is + /// not re-seeded from persisted settings. + CreatingNew, +} + +impl AuthSecretSelection { + /// `Some(name)` → `Named`, `None` → `Unset`. Wire payloads and persisted + /// settings carry only the name, so absence always means "no choice yet". + pub fn from_optional_name(name: Option) -> Self { + match name { + Some(name) if !name.trim().is_empty() => Self::Named(name), + _ => Self::Unset, + } + } +} + +/// Run-wide configuration fields shared between the confirmation card +/// editor and the plan-card config block. Card-specific fields +/// (agent_run_configs, base_prompt, summary, skills) +/// remain on the per-view state structs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrchestrationConfigState { + pub model_id: String, + pub harness_type: String, + pub execution_mode: RunAgentsExecutionMode, + /// Drives the picker display and Accept gate. Persisted as + /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. + pub auth_secret_selection: AuthSecretSelection, +} + +impl OrchestrationConfigState { + /// Returns the on-wire secret name; `None` for `Inherit`, `Unset`, or + /// when the current mode/harness doesn't support managed auth secrets + /// (Local, Oz, or harnesses without managed-secret types). Gating on + /// visibility here prevents a stale `Named(_)` left over from a prior + /// Cloud/non-Oz config from leaking into the on-wire payload after the + /// user toggles to Local or switches to a harness without auth. + pub fn auth_secret_name(&self) -> Option<&str> { + if !should_show_auth_secret_picker(self) { + return None; + } + match &self.auth_secret_selection { + AuthSecretSelection::Named(name) => Some(name.as_str()), + AuthSecretSelection::Inherit + | AuthSecretSelection::Unset + | AuthSecretSelection::CreatingNew => None, + } + } + + /// User picked "New API key…"; mark `CreatingNew` to block Accept until a + /// key is created or another option is chosen. + pub fn select_create_new_auth_secret(&mut self) { + self.auth_secret_selection = AuthSecretSelection::CreatingNew; + } +} + +impl OrchestrationConfigState { + pub(crate) fn sanitize_for_local_execution(&mut self) { + let Some(harness) = Harness::parse_local_child_harness(&self.harness_type) else { + return; + }; + if local_harness_product_disabled_message(harness).is_some() { + self.harness_type = "oz".to_string(); + self.model_id.clear(); + } + } + + pub fn from_run_agents_fields( + model_id: &str, + harness_type: &str, + execution_mode: &RunAgentsExecutionMode, + ) -> Self { + Self { + model_id: model_id.to_string(), + harness_type: harness_type.to_string(), + execution_mode: execution_mode.clone(), + auth_secret_selection: AuthSecretSelection::Unset, + } + } + + pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self { + let execution_mode = match &config.execution_mode { + OrchestrationExecutionMode::Local => RunAgentsExecutionMode::Local, + OrchestrationExecutionMode::Remote { + environment_id, + worker_host, + } => RunAgentsExecutionMode::Remote { + environment_id: environment_id.clone(), + worker_host: worker_host.clone(), + computer_use_enabled: false, + }, + }; + let mut state = Self { + model_id: config.model_id.clone(), + harness_type: config.harness_type.clone(), + execution_mode, + auth_secret_selection: AuthSecretSelection::Unset, + }; + if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { + state.sanitize_for_local_execution(); + } + state + } + + /// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching + /// to Cloud (unsupported combination). + pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) { + if is_remote { + if self.harness_type.eq_ignore_ascii_case("opencode") { + self.harness_type = "oz".to_string(); + } + if !self.execution_mode.is_remote() { + self.execution_mode = RunAgentsExecutionMode::Remote { + environment_id: String::new(), + worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), + computer_use_enabled: false, + }; + } + } else { + self.execution_mode = RunAgentsExecutionMode::Local; + self.sanitize_for_local_execution(); + } + } + + pub fn set_environment_id(&mut self, environment_id: String) { + if let RunAgentsExecutionMode::Remote { + environment_id: id, .. + } = &mut self.execution_mode + { + *id = environment_id; + } + } + + pub fn set_worker_host(&mut self, worker_host: String) { + if let RunAgentsExecutionMode::Remote { + worker_host: wh, .. + } = &mut self.execution_mode + { + *wh = worker_host; + } + } + + /// Returns `Some(reason)` if Accept / Apply must be disabled. + /// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses. + pub fn accept_disabled_reason(&self) -> Option<&'static str> { + match &self.execution_mode { + RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type) + .and_then(local_harness_product_disabled_message), + RunAgentsExecutionMode::Remote { .. } + if self.harness_type.eq_ignore_ascii_case("opencode") => + { + Some( + "OpenCode is not supported on Cloud yet. Switch to Local or pick a different harness.", + ) + } + RunAgentsExecutionMode::Remote { .. } => None, + } + } + + /// Fills in empty fields from the approved orchestration config. + /// When the LLM omits harness/model/execution_mode to inherit from + /// the active config, the raw request arrives with defaults (empty + /// harness, empty model, Local mode). This resolves those to the + /// config values so the UI shows the intended settings. + pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) { + if self.harness_type.is_empty() && !config.harness_type.is_empty() { + self.harness_type = config.harness_type.clone(); + } + if self.model_id.is_empty() && !config.model_id.is_empty() { + self.model_id = config.model_id.clone(); + } + if !self.execution_mode.is_remote() && config.execution_mode.is_remote() { + self.execution_mode = Self::from_orchestration_config(config).execution_mode; + } + if matches!(self.execution_mode, RunAgentsExecutionMode::Local) { + self.sanitize_for_local_execution(); + } + } + + /// Unconditionally overrides model, harness, and execution mode + /// from the approved orchestration config. The plan config is the + /// user-approved source of truth — the LLM's run_agents call may + /// omit or set these differently, but the config always wins. + /// + /// `computer_use_enabled` is preserved from the current state when + /// both sides are Remote, since it is a per-call flag set by the LLM. + pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) { + self.model_id = config.model_id.clone(); + self.harness_type = config.harness_type.clone(); + + let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { + ( + RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + }, + OrchestrationExecutionMode::Remote { .. }, + ) => Some(*computer_use_enabled), + _ => None, + }; + + self.execution_mode = Self::from_orchestration_config(config).execution_mode; + + if let ( + Some(cue), + RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + }, + ) = (preserve_computer_use, &mut self.execution_mode) + { + *computer_use_enabled = cue; + } + } + + /// Converts to a native `OrchestrationConfig` for storage / match. + pub fn to_orchestration_config(&self) -> OrchestrationConfig { + let execution_mode = match &self.execution_mode { + RunAgentsExecutionMode::Local => OrchestrationExecutionMode::Local, + RunAgentsExecutionMode::Remote { + environment_id, + worker_host, + .. + } => OrchestrationExecutionMode::Remote { + environment_id: environment_id.clone(), + worker_host: worker_host.clone(), + }, + }; + OrchestrationConfig { + model_id: self.model_id.clone(), + harness_type: self.harness_type.clone(), + execution_mode, + } + } +} + +#[cfg(test)] +#[path = "config_state_tests.rs"] +mod tests; diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs new file mode 100644 index 00000000000..88f93bc75ed --- /dev/null +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -0,0 +1,85 @@ +use ai::agent::action::RunAgentsExecutionMode; +use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; + +use super::OrchestrationConfigState; + +fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { + OrchestrationConfig { + model_id: model_id.to_string(), + harness_type: harness_type.to_string(), + execution_mode: OrchestrationExecutionMode::Local, + } +} + +#[test] +fn toggle_to_local_sanitizes_disabled_codex() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + "gpt-5", + "codex", + &RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + }, + ); + + state.toggle_execution_mode_to_remote(false); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + +#[test] +fn toggle_to_local_preserves_claude() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + "sonnet", + "claude", + &RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + }, + ); + + state.toggle_execution_mode_to_remote(false); + + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "sonnet"); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + +#[test] +fn resolve_from_config_preserves_local_claude() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); + + state.resolve_from_config(&local_config("claude", "sonnet")); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "sonnet"); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + +#[test] +fn resolve_from_config_sanitizes_disabled_local_codex() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); + + state.resolve_from_config(&local_config("codex", "gpt-5")); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); +} diff --git a/app/src/ai/orchestration/edit_state.rs b/app/src/ai/orchestration/edit_state.rs new file mode 100644 index 00000000000..4b89c49be51 --- /dev/null +++ b/app/src/ai/orchestration/edit_state.rs @@ -0,0 +1,271 @@ +//! [`OrchestrationEditState`] and the logic for applying user edits to +//! the orchestration config, shared by the GUI pickers and the TUI +//! configuration pages. Changing one field (harness, execution mode, +//! auth secret) cascades into dependent fields — model fallback, +//! environment pre-fill, auth re-resolution — so those updates live here +//! as `apply_*` methods rather than in each frontend. Each public method +//! takes an `AppContext` for catalog/persistence access; the cores are +//! parameterized over catalog callbacks so they can be unit-tested +//! without app singletons. + +use std::collections::HashMap; + +use ai::agent::action::RunAgentsExecutionMode; +use warp_cli::agent::Harness; +use warpui::{AppContext, SingletonEntity}; + +use super::config_state::{AuthSecretSelection, OrchestrationConfigState}; +use super::providers::{ + first_filtered_model_id, harness_save_key, is_model_in_filtered_choices, + persist_auth_secret_selection, resolve_auth_secret_selection_for_harness, + resolve_default_environment_id, +}; +use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; + +impl OrchestrationConfigState { + /// Toggles Local ↔ Cloud, pre-fills the default environment when + /// switching to Cloud with no environment selected, and revalidates + /// the model against the new mode's catalog. + pub fn apply_execution_mode_change( + &mut self, + is_remote: bool, + fallback_base_model_id: Option, + ctx: &AppContext, + ) { + let default_environment_id = if is_remote { + resolve_default_environment_id(ctx) + } else { + None + }; + self.apply_execution_mode_change_core( + is_remote, + fallback_base_model_id, + default_environment_id, + &|id, harness, is_local| is_model_in_filtered_choices(id, harness, is_local, ctx), + &|harness| first_filtered_model_id(harness, ctx), + ); + } + + /// Records the auth-secret picker choice (`None` means Inherit) and + /// persists it to `CloudAgentSettings`. + pub fn apply_auth_secret_change(&mut self, new_name: Option, ctx: &mut AppContext) { + let normalized = new_name.filter(|s| !s.trim().is_empty()); + self.auth_secret_selection = match normalized { + Some(name) => AuthSecretSelection::Named(name), + None => AuthSecretSelection::Inherit, + }; + persist_auth_secret_selection(&self.harness_type, &self.auth_secret_selection, ctx); + } + + /// Revalidates the state after a live catalog change: resets a + /// vanished model to the harness default, drops a deleted `Named(_)` + /// secret, and re-seeds an `Unset` selection from persisted settings. + /// This is the frontend-neutral core of the GUI's + /// `repopulate_all_pickers`. + pub fn revalidate_after_catalog_change(&mut self, ctx: &AppContext) { + let loaded_secret_names = Harness::parse_orchestration_harness(&self.harness_type) + .filter(|harness| *harness != Harness::Oz) + .and_then(|harness| { + match HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness) { + AuthSecretFetchState::Loaded(secrets) => { + Some(secrets.iter().map(|s| s.name.clone()).collect::>()) + } + AuthSecretFetchState::NotFetched + | AuthSecretFetchState::Loading + | AuthSecretFetchState::Failed(_) => None, + } + }); + let reseeded_selection = resolve_auth_secret_selection_for_harness(&self.harness_type, ctx); + self.revalidate_after_catalog_change_core( + loaded_secret_names.as_deref(), + reseeded_selection, + &|id, harness, is_local| is_model_in_filtered_choices(id, harness, is_local, ctx), + &|harness| first_filtered_model_id(harness, ctx), + ); + } + + /// Core of [`Self::apply_execution_mode_change`]; catalog access is + /// injected so tests can drive it without app singletons. + fn apply_execution_mode_change_core( + &mut self, + is_remote: bool, + fallback_base_model_id: Option, + default_environment_id: Option, + model_is_valid: &dyn Fn(&str, &str, bool) -> bool, + default_model_id: &dyn Fn(&str) -> Option, + ) { + self.toggle_execution_mode_to_remote(is_remote); + let is_local = !self.execution_mode.is_remote(); + // Pre-fill environment with the last-selected one when switching + // to Cloud. + if is_remote { + if let RunAgentsExecutionMode::Remote { environment_id, .. } = &self.execution_mode { + if environment_id.is_empty() { + if let Some(default_env) = default_environment_id { + self.set_environment_id(default_env); + } + } + } + } + self.reset_model_if_invalid( + fallback_base_model_id, + is_local, + model_is_valid, + default_model_id, + ); + } + + /// Core of [`Self::revalidate_after_catalog_change`]. + /// `loaded_secret_names` is `Some` only when secrets for the active + /// non-Oz harness are loaded; `reseeded_selection` is the persisted + /// selection used to replace `Unset`. + fn revalidate_after_catalog_change_core( + &mut self, + loaded_secret_names: Option<&[String]>, + reseeded_selection: AuthSecretSelection, + model_is_valid: &dyn Fn(&str, &str, bool) -> bool, + default_model_id: &dyn Fn(&str) -> Option, + ) { + let is_local = !self.execution_mode.is_remote(); + if is_local { + self.sanitize_for_local_execution(); + } + // Reset model if it disappeared from the harness's catalog. + if !model_is_valid(&self.model_id, &self.harness_type, is_local) { + if let Some(first_id) = default_model_id(&self.harness_type) { + self.model_id = first_id; + } + } + // Drop any `Named(_)` selection whose secret no longer exists. + if let (Some(names), AuthSecretSelection::Named(name)) = + (loaded_secret_names, &self.auth_secret_selection) + { + if !names.iter().any(|n| n == name) { + self.auth_secret_selection = AuthSecretSelection::Unset; + } + } + // Re-seed `Unset` from persisted settings. Leaves `Inherit` alone. + // Uses the full selection resolver so a prior explicit Inherit is + // restored (rather than being downgraded to Unset). + if matches!(self.auth_secret_selection, AuthSecretSelection::Unset) + && !matches!(reseeded_selection, AuthSecretSelection::Unset) + { + self.auth_secret_selection = reseeded_selection; + } + } + + /// Resets `model_id` when it is invalid for the current harness/mode: + /// prefers the (validated) fallback, then the harness default. + fn reset_model_if_invalid( + &mut self, + fallback_base_model_id: Option, + is_local: bool, + model_is_valid: &dyn Fn(&str, &str, bool) -> bool, + default_model_id: &dyn Fn(&str) -> Option, + ) { + if !model_is_valid(&self.model_id, &self.harness_type, is_local) { + let reset_id = fallback_base_model_id + .filter(|id| model_is_valid(id, &self.harness_type, is_local)) + .or_else(|| default_model_id(&self.harness_type)) + .unwrap_or_default(); + self.model_id = reset_id; + } + } +} + +/// The edit state for one orchestration card: the run-wide config being +/// edited plus the per-harness model memory, which is UI state rather +/// than request state. Card views own one of these; the executor keeps +/// constructing a bare [`OrchestrationConfigState`] and never carries the +/// memory map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrchestrationEditState { + pub orchestration_config_state: OrchestrationConfigState, + /// Per-harness model memory so switching harnesses preserves the + /// user's previous model selection for each harness. Keyed by + /// [`harness_save_key`]. + pub saved_model_per_harness: HashMap, +} + +impl OrchestrationEditState { + /// Wraps `orchestration_config_state` with empty per-harness memory. + pub fn new(orchestration_config_state: OrchestrationConfigState) -> Self { + Self { + orchestration_config_state, + saved_model_per_harness: HashMap::new(), + } + } + + /// Handles a harness change: saves the current model for the old + /// harness, restores a previously saved (still valid) model for the + /// new harness or falls back to a default, and re-resolves the auth + /// secret selection for the new harness. + pub fn apply_harness_change( + &mut self, + new_harness_type: &str, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ) { + let resolved_auth = resolve_auth_secret_selection_for_harness(new_harness_type, ctx); + let ctx: &AppContext = ctx; + self.apply_harness_change_core( + new_harness_type, + fallback_base_model_id, + resolved_auth, + &|id, harness, is_local| is_model_in_filtered_choices(id, harness, is_local, ctx), + &|harness| first_filtered_model_id(harness, ctx), + ); + } + + /// Core of [`Self::apply_harness_change`]; catalog access and the + /// resolved auth selection are injected for unit testing. + fn apply_harness_change_core( + &mut self, + new_harness_type: &str, + fallback_base_model_id: Option, + resolved_auth: AuthSecretSelection, + model_is_valid: &dyn Fn(&str, &str, bool) -> bool, + default_model_id: &dyn Fn(&str) -> Option, + ) { + // Save current model for the old harness. + let old_key = harness_save_key(&self.orchestration_config_state.harness_type).to_string(); + self.saved_model_per_harness + .insert(old_key, self.orchestration_config_state.model_id.clone()); + self.orchestration_config_state.harness_type = new_harness_type.to_string(); + + let is_local = !self.orchestration_config_state.execution_mode.is_remote(); + if is_local { + self.orchestration_config_state + .sanitize_for_local_execution(); + } + // Try to restore a previously saved model for this harness. + let new_key = harness_save_key(&self.orchestration_config_state.harness_type); + let restored = self + .saved_model_per_harness + .get(new_key) + .filter(|id| { + model_is_valid(id, &self.orchestration_config_state.harness_type, is_local) + }) + .cloned(); + if let Some(saved_id) = restored { + self.orchestration_config_state.model_id = saved_id; + } else { + // No saved model — fall back to conversation base model + // for Oz, or default for non-Oz. + self.orchestration_config_state.reset_model_if_invalid( + fallback_base_model_id, + is_local, + model_is_valid, + default_model_id, + ); + } + + // Re-resolve auth selection from per-harness persisted state. + // Honors an explicit `Inherit` choice for the new harness. + self.orchestration_config_state.auth_secret_selection = resolved_auth; + } +} + +#[cfg(test)] +#[path = "edit_state_tests.rs"] +mod tests; diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs new file mode 100644 index 00000000000..9a48272e282 --- /dev/null +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use ai::agent::action::RunAgentsExecutionMode; + +use super::OrchestrationEditState; +use crate::ai::orchestration::config_state::{AuthSecretSelection, OrchestrationConfigState}; + +fn remote_mode() -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + } +} + +fn model_valid_among<'a>(valid: &'a [&'a str]) -> impl Fn(&str, &str, bool) -> bool + 'a { + move |id, _harness, _is_local| valid.contains(&id) +} + +#[test] +fn execution_mode_change_to_local_forces_oz_and_strips_cloud_fields() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("gpt-5", "codex", &remote_mode()); + + state.apply_execution_mode_change_core(false, None, None, &model_valid_among(&[""]), &|_| { + Some(String::new()) + }); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + +#[test] +fn execution_mode_change_to_cloud_prefills_default_environment() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + "auto", + "oz", + &RunAgentsExecutionMode::Local, + ); + + state.apply_execution_mode_change_core( + true, + None, + Some("env-42".to_string()), + &model_valid_among(&["auto"]), + &|_| Some("auto".to_string()), + ); + + assert!(matches!( + &state.execution_mode, + RunAgentsExecutionMode::Remote { environment_id, .. } if environment_id == "env-42" + )); + assert_eq!(state.model_id, "auto"); +} + +#[test] +fn execution_mode_change_prefers_valid_fallback_over_default_model() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + "stale", + "oz", + &RunAgentsExecutionMode::Local, + ); + + state.apply_execution_mode_change_core( + true, + Some("fallback".to_string()), + None, + &model_valid_among(&["fallback", "first"]), + &|_| Some("first".to_string()), + ); + + assert_eq!(state.model_id, "fallback"); +} + +#[test] +fn harness_change_saves_and_restores_per_harness_model_memory() { + let state = + OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + let mut edit_state = OrchestrationEditState { + orchestration_config_state: state, + saved_model_per_harness: HashMap::from([("codex".to_string(), "gpt-5".to_string())]), + }; + + edit_state.apply_harness_change_core( + "codex", + None, + AuthSecretSelection::Unset, + &model_valid_among(&["gpt-5", "sonnet", ""]), + &|_| Some(String::new()), + ); + + // Restored the saved codex model and remembered the claude model. + assert_eq!(edit_state.orchestration_config_state.harness_type, "codex"); + assert_eq!(edit_state.orchestration_config_state.model_id, "gpt-5"); + assert_eq!( + edit_state.saved_model_per_harness.get("claude"), + Some(&"sonnet".to_string()) + ); +} + +#[test] +fn harness_change_applies_resolved_auth_selection() { + let mut state = OrchestrationConfigState::from_run_agents_fields("auto", "oz", &remote_mode()); + state.auth_secret_selection = AuthSecretSelection::Named("old-key".to_string()); + let mut edit_state = OrchestrationEditState::new(state); + + edit_state.apply_harness_change_core( + "claude", + None, + AuthSecretSelection::Named("anthropic-key".to_string()), + &model_valid_among(&["auto"]), + &|_| Some("auto".to_string()), + ); + + assert_eq!( + edit_state.orchestration_config_state.auth_secret_selection, + AuthSecretSelection::Named("anthropic-key".to_string()) + ); +} + +#[test] +fn revalidate_drops_deleted_named_secret_and_reseeds_from_resolved() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + state.auth_secret_selection = AuthSecretSelection::Named("deleted-key".to_string()); + + state.revalidate_after_catalog_change_core( + Some(&["other-key".to_string()]), + AuthSecretSelection::Named("other-key".to_string()), + &model_valid_among(&["sonnet"]), + &|_| Some("sonnet".to_string()), + ); + + assert_eq!( + state.auth_secret_selection, + AuthSecretSelection::Named("other-key".to_string()) + ); +} + +#[test] +fn revalidate_keeps_named_secret_still_present() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string()); + + state.revalidate_after_catalog_change_core( + Some(&["my-key".to_string()]), + AuthSecretSelection::Unset, + &model_valid_among(&["sonnet"]), + &|_| Some("sonnet".to_string()), + ); + + assert_eq!( + state.auth_secret_selection, + AuthSecretSelection::Named("my-key".to_string()) + ); +} + +#[test] +fn revalidate_leaves_explicit_inherit_alone() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + state.auth_secret_selection = AuthSecretSelection::Inherit; + + state.revalidate_after_catalog_change_core( + Some(&[]), + AuthSecretSelection::Named("persisted".to_string()), + &model_valid_among(&["sonnet"]), + &|_| Some("sonnet".to_string()), + ); + + assert_eq!(state.auth_secret_selection, AuthSecretSelection::Inherit); +} + +#[test] +fn revalidate_resets_vanished_model_to_default() { + let mut state = + OrchestrationConfigState::from_run_agents_fields("gone", "claude", &remote_mode()); + + state.revalidate_after_catalog_change_core( + None, + AuthSecretSelection::Unset, + &model_valid_among(&[""]), + &|_| Some(String::new()), + ); + + assert_eq!(state.model_id, ""); +} diff --git a/app/src/ai/orchestration/mod.rs b/app/src/ai/orchestration/mod.rs new file mode 100644 index 00000000000..79842b42510 --- /dev/null +++ b/app/src/ai/orchestration/mod.rs @@ -0,0 +1,33 @@ +//! Frontend-neutral orchestration domain: edit state, transitions, +//! validation, and catalog providers shared by the GUI orchestration +//! controls and the TUI orchestration card. +//! +//! Nothing in this module may depend on `warpui::elements` or any other +//! GUI rendering types; it only reads/writes app singletons through +//! `AppContext`. + +mod config_state; +mod edit_state; +mod providers; +mod validation; + +pub use config_state::{AuthSecretSelection, OrchestrationConfigState}; +pub use edit_state::OrchestrationEditState; +pub(crate) use providers::{ + can_execute_with_auth_secret, get_base_model_choices, persist_auth_secret_selection, + populate_default_auth_secret_for_execution, +}; +pub use providers::{ + persist_environment_selection, persist_host_selection, + resolve_auth_secret_selection_for_harness, resolve_default_environment_id, + resolve_default_host_slug, resolve_recent_host_slug, ORCHESTRATION_ENV_NONE_LABEL, + ORCHESTRATION_WARP_WORKER_HOST, +}; +// Consumed by the TUI via `tui_export`; the GUI gates Accept through +// `accept_disabled_reason_with_auth` instead. +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use validation::auth_secret_selection_required; +pub use validation::{ + accept_disabled_reason_with_auth, empty_env_recommendation_message, harness_is_selectable, + should_show_auth_secret_picker, +}; diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs new file mode 100644 index 00000000000..9c93c6c3e59 --- /dev/null +++ b/app/src/ai/orchestration/providers.rs @@ -0,0 +1,362 @@ +//! `AppContext`-backed catalog lookups, default resolution, and +//! persistence helpers for orchestration edit flows. No GUI types. + +use ai::agent::action::RunAgentsRequest; +use settings::Setting; +use warp_cli::agent::Harness; +use warp_errors::report_if_error; +use warpui::{AppContext, SingletonEntity}; + +use crate::ai::auth_secret_types::auth_secret_types_for_harness; +use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; +use crate::ai::connected_self_hosted_workers::WARP_WORKER_HOST; +use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; +use crate::ai::llms::LLMInfo; +use crate::ai::orchestration::config_state::AuthSecretSelection; +use crate::cloud_object::CloudObjectLookup as _; +use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::LLMPreferences; + +/// Env var override for the workspace default host (developer testing). +/// Mirrors the single-agent ambient flow. +const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; + +pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST; +pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment"; + +/// Returns Warp base-model choices for orchestration. +pub(crate) fn get_base_model_choices<'a>( + llm_prefs: &'a LLMPreferences, + app: &'a AppContext, + is_local: bool, +) -> impl Iterator { + llm_prefs + .get_base_llm_choices_for_agent_mode(app) + .filter(move |llm| is_local || llm_prefs.custom_llm_info_for_id(&llm.id).is_none()) +} + +/// Returns whether the given model_id is present in the harness-filtered +/// model choices. Used to detect when a harness change invalidates the +/// current model selection. +pub fn is_model_in_filtered_choices( + model_id: &str, + harness_type: &str, + is_local: bool, + ctx: &AppContext, +) -> bool { + let harness = Harness::parse_orchestration_harness(harness_type); + match harness { + Some(Harness::Oz) | None => { + let llm_prefs = LLMPreferences::as_ref(ctx); + get_base_model_choices(llm_prefs, ctx, is_local) + .any(|llm| llm.id.to_string() == model_id) + } + Some(Harness::Codex) if is_local => model_id.is_empty(), + Some(harness) => { + // Empty string is always valid (the "Default model" entry). + if model_id.is_empty() { + return true; + } + let availability = HarnessAvailabilityModel::as_ref(ctx); + availability + .models_for(harness) + .is_some_and(|models| models.iter().any(|m| m.id == model_id)) + } + } +} + +/// Returns the default model_id for the given harness. +/// +/// For Oz this is the first Warp LLM; for non-Oz harnesses it is an empty +/// string (the "Default model" entry). +pub fn first_filtered_model_id(harness_type: &str, ctx: &AppContext) -> Option { + let harness = Harness::parse_orchestration_harness(harness_type); + match harness { + Some(Harness::Oz) | None => { + let llm_prefs = LLMPreferences::as_ref(ctx); + llm_prefs + .get_base_llm_choices_for_agent_mode(ctx) + .next() + .map(|llm| llm.id.to_string()) + } + Some(_) => Some(String::new()), + } +} + +/// Resolves the workspace-configured default host slug, honoring the +/// `WARP_CLOUD_MODE_DEFAULT_HOST` env var override for developer +/// testing. Mirrors the single-agent ambient flow. +pub fn resolve_default_host_slug(ctx: &AppContext) -> Option { + if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) { + let trimmed = slug.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + UserWorkspaces::as_ref(ctx) + .default_host_slug() + .map(str::to_string) + .filter(|s| !s.trim().is_empty()) +} + +/// Returns the user's last-selected custom host slug from +/// `CloudAgentSettings.last_selected_host`, excluding `"warp"` and the +/// workspace default (those are surfaced as separate menu rows). +pub fn resolve_recent_host_slug(ctx: &AppContext) -> Option { + let last = CloudAgentSettings::as_ref(ctx) + .last_selected_host + .value() + .clone() + .filter(|s| !s.trim().is_empty())?; + if last.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return None; + } + if resolve_default_host_slug(ctx).as_deref() == Some(last.as_str()) { + return None; + } + Some(last) +} + +/// Persists the user's most-recent host selection to +/// `CloudAgentSettings.last_selected_host`. Skipped for `"warp"` and +/// empty values (those don't represent a custom slug worth remembering). +pub fn persist_host_selection(worker_host: &str, ctx: &mut AppContext) { + let trimmed = worker_host.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return; + } + let value = trimmed.to_string(); + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.last_selected_host.set_value(Some(value), ctx)); + }); +} + +/// Normalizes a harness_type string for use as a HashMap key in +/// per-harness model memory. Empty string (the wire representation +/// of Oz) is mapped to "oz" so saves and lookups are consistent. +pub fn harness_save_key(harness_type: &str) -> &str { + if harness_type.is_empty() { + "oz" + } else { + harness_type + } +} + +/// Resolves a default environment ID using the same logic as the +/// `/cloud-agent` environment selector: first tries the user's +/// last-selected environment from settings, then falls back to the +/// most recently used environment. +pub fn resolve_default_environment_id(ctx: &AppContext) -> Option { + if let Some(env_id) = *CloudAgentSettings::as_ref(ctx) + .last_selected_environment_id + .value() + { + if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() { + return Some(env_id.uid()); + } + } + let mut envs = CloudAmbientAgentEnvironment::get_all(ctx); + envs.sort_by(|a, b| { + b.metadata + .last_task_run_ts + .cmp(&a.metadata.last_task_run_ts) + .then_with(|| { + a.model() + .string_model + .name + .cmp(&b.model().string_model.name) + }) + }); + envs.first().map(|e| e.id.uid()) +} + +/// Persists the user's environment selection to settings so it can +/// be restored as the default next time. Shared by both the plan +/// card and confirmation card `EnvironmentChanged` handlers. +pub fn persist_environment_selection(environment_id: &str, ctx: &mut AppContext) { + if environment_id.is_empty() { + return; + } + let all_envs = CloudAmbientAgentEnvironment::get_all(ctx); + if let Some(env) = all_envs.iter().find(|e| e.id.uid() == environment_id) { + let sync_id = env.id; + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + if let Err(e) = settings + .last_selected_environment_id + .set_value(Some(sync_id), ctx) + { + log::warn!("Failed to persist environment selection: {e:?}"); + } + }); + } +} + +/// Returns the persisted last-selected secret name for this harness, or +/// `None`. Only promotes a persisted name; never auto-picks the first +/// loaded secret. Validates against the loaded secrets list when present, +/// returning `None` if the persisted name has been deleted server-side. +pub fn resolve_default_auth_secret_for_harness( + harness_type: &str, + ctx: &AppContext, +) -> Option { + let harness = Harness::parse_orchestration_harness(harness_type)?; + if harness == Harness::Oz { + return None; + } + let persisted = CloudAgentSettings::as_ref(ctx) + .last_selected_auth_secret + .value() + .get(harness.config_name()) + .cloned() + .filter(|name| !name.trim().is_empty()); + + let availability = HarnessAvailabilityModel::as_ref(ctx); + match availability.auth_secrets_for(harness) { + AuthSecretFetchState::Loaded(secrets) => { + // Drop the persisted name if the secret was deleted server-side. + persisted.filter(|name| secrets.iter().any(|s| s.name == *name)) + } + // Pre-fetch: optimistically show the persisted name; the + // `AuthSecretsLoaded` subscription will re-resolve. + AuthSecretFetchState::NotFetched + | AuthSecretFetchState::Loading + | AuthSecretFetchState::Failed(_) => persisted, + } +} + +/// Returns the full persisted selection (Named / Inherit / Unset) for +/// this harness. Prefers an explicit `Inherit` choice over a `Named` +/// fallback so the plan card's "Inherit" survives across the RunAgents +/// handoff (the `OrchestrationConfig` proto doesn't carry auth state). +pub fn resolve_auth_secret_selection_for_harness( + harness_type: &str, + ctx: &AppContext, +) -> AuthSecretSelection { + let Some(harness) = Harness::parse_orchestration_harness(harness_type) else { + return AuthSecretSelection::Unset; + }; + if harness == Harness::Oz { + return AuthSecretSelection::Unset; + } + // Explicit Inherit wins over a stale Named fallback. + let inherit_chosen = CloudAgentSettings::as_ref(ctx) + .inherit_auth_secret_harnesses + .value() + .get(harness.config_name()) + .copied() + .unwrap_or(false); + if inherit_chosen { + return AuthSecretSelection::Inherit; + } + match resolve_default_auth_secret_for_harness(harness_type, ctx) { + Some(name) => AuthSecretSelection::Named(name), + None => AuthSecretSelection::Unset, + } +} + +/// Persists the user's auth-secret choice for the active harness. +/// `Named` writes to `last_selected_auth_secret` and clears any prior +/// `Inherit` flag. `Inherit` clears the named entry and sets the inherit +/// flag. `Unset`/`CreatingNew` clear both (no recorded choice). No-op for +/// Oz / unknown. +pub(crate) fn persist_auth_secret_selection( + harness_type: &str, + selection: &AuthSecretSelection, + ctx: &mut AppContext, +) { + let Some(harness) = Harness::parse_orchestration_harness(harness_type) else { + return; + }; + if harness == Harness::Oz { + return; + } + let key = harness.config_name().to_string(); + let selection = selection.clone(); + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + let mut named_map = settings.last_selected_auth_secret.value().clone(); + let mut inherit_map = settings.inherit_auth_secret_harnesses.value().clone(); + match selection { + AuthSecretSelection::Named(name) => { + named_map.insert(key.clone(), name.clone()); + inherit_map.remove(&key); + } + AuthSecretSelection::Inherit => { + named_map.remove(&key); + inherit_map.insert(key, true); + } + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { + named_map.remove(&key); + inherit_map.remove(&key); + } + } + report_if_error!(settings.last_selected_auth_secret.set_value(named_map, ctx)); + report_if_error!(settings + .inherit_auth_secret_harnesses + .set_value(inherit_map, ctx)); + }); +} + +/// Whether Remote execution of `request` requires a managed auth secret +/// (non-Oz cloud harness with at least one supported secret type). +fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> bool { + if !request.execution_mode.is_remote() { + return false; + } + let Some(harness) = Harness::parse_orchestration_harness(&request.harness_type) else { + return false; + }; + harness != Harness::Oz && !auth_secret_types_for_harness(harness).is_empty() +} + +/// Whether the request can execute as-is: either it doesn't need a +/// managed auth secret, already carries one, or a persisted default +/// exists for the harness. +pub(crate) fn can_execute_with_auth_secret(request: &RunAgentsRequest, ctx: &AppContext) -> bool { + if !requires_default_auth_secret_for_execution(request) { + return true; + } + if request + .harness_auth_secret_name + .as_deref() + .is_some_and(|name| !name.trim().is_empty()) + { + return true; + } + default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some() +} + +/// Returns the persisted default managed-secret name for a harness, if any. +pub(crate) fn default_auth_secret_name_for_harness( + harness_type: &str, + ctx: &AppContext, +) -> Option { + let harness = Harness::parse_orchestration_harness(harness_type)?; + if harness == Harness::Oz { + return None; + } + CloudAgentSettings::as_ref(ctx) + .last_selected_auth_secret + .value() + .get(harness.config_name()) + .cloned() + .filter(|name| !name.trim().is_empty()) +} + +/// Fills `harness_auth_secret_name` from the persisted per-harness default +/// when the request needs one and doesn't already carry a name. +pub(crate) fn populate_default_auth_secret_for_execution( + request: &mut RunAgentsRequest, + ctx: &AppContext, +) { + if !requires_default_auth_secret_for_execution(request) + || request + .harness_auth_secret_name + .as_deref() + .is_some_and(|name| !name.trim().is_empty()) + { + return; + } + request.harness_auth_secret_name = + default_auth_secret_name_for_harness(&request.harness_type, ctx); +} diff --git a/app/src/ai/orchestration/validation.rs b/app/src/ai/orchestration/validation.rs new file mode 100644 index 00000000000..4520e0bd51e --- /dev/null +++ b/app/src/ai/orchestration/validation.rs @@ -0,0 +1,134 @@ +//! Frontend-neutral validation predicates for orchestration edit flows. + +use ai::agent::action::RunAgentsExecutionMode; +use warp_cli::agent::Harness; +use warpui::AppContext; + +use super::config_state::{AuthSecretSelection, OrchestrationConfigState}; +use crate::ai::auth_secret_types::auth_secret_types_for_harness; +use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; +use crate::ai::local_harness_setup::{ + local_harness_is_product_enabled, local_harness_setup_state, LocalHarnessSetupState, +}; +use crate::ai::orchestration::providers::ORCHESTRATION_WARP_WORKER_HOST; +use crate::cloud_object::CloudObjectLookup as _; + +/// Whether a harness's local setup allows selecting it: always true for +/// Cloud, otherwise requires the local CLI to be installed and the +/// harness to be product-enabled. +pub(crate) fn local_harness_setup_is_ready(harness: Harness, is_local: bool) -> bool { + !is_local || local_harness_setup_state(harness).is_selectable() +} + +/// Whether a harness can be confirmed as the run-wide harness: excludes +/// Gemini (not yet supported for multi-agent runs), product-disabled +/// local harnesses, and local harnesses whose CLI setup is not ready. +/// Both frontends must filter/disable identically through this predicate. +pub fn harness_is_selectable(harness: Harness, is_local: bool) -> bool { + if harness == Harness::Gemini { + return false; + } + if is_local && !local_harness_is_product_enabled(harness) { + return false; + } + local_harness_setup_is_ready(harness, is_local) +} + +/// Returns `true` when the auth secret picker should be visible: Cloud + +/// non-Oz + a harness with at least one supported auth-secret type. Local +/// non-Oz children inherit auth from the user's shell environment. +pub fn should_show_auth_secret_picker(state: &OrchestrationConfigState) -> bool { + if !state.execution_mode.is_remote() { + return false; + } + let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { + return false; + }; + if harness == Harness::Oz { + return false; + } + !auth_secret_types_for_harness(harness).is_empty() +} + +/// `true` when the user must pick an API key (or Inherit) before Accept is +/// allowed. Fires on `Unset` for any non-Oz cloud harness with managed-secret +/// types, regardless of fetch state — dispatching with an unintended +/// `Inherit` while secrets are still loading would fail downstream. +pub fn auth_secret_selection_required(state: &OrchestrationConfigState, _ctx: &AppContext) -> bool { + if !should_show_auth_secret_picker(state) { + return false; + } + if !matches!( + state.auth_secret_selection, + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew + ) { + return false; + } + let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { + return false; + }; + if harness == Harness::Oz || auth_secret_types_for_harness(harness).is_empty() { + return false; + } + true +} + +/// [`OrchestrationConfigState::accept_disabled_reason`] plus the +/// auth-secret-selection gate. Card views should prefer this. +pub fn accept_disabled_reason_with_auth( + state: &OrchestrationConfigState, + ctx: &AppContext, +) -> Option { + if let Some(reason) = state.accept_disabled_reason() { + return Some(reason.to_string()); + } + if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { + if let Some(harness) = Harness::parse_local_child_harness(&state.harness_type) { + match local_harness_setup_state(harness) { + LocalHarnessSetupState::MissingHarness { tooltip } => { + return Some(tooltip.to_string()); + } + LocalHarnessSetupState::ProductDisabled { message } => { + return Some(message.to_string()); + } + LocalHarnessSetupState::Ready => {} + } + } + } + if auth_secret_selection_required(state, ctx) { + return Some("Select an API key for this harness to continue.".to_string()); + } + None +} + +/// Soft recommendation copy shown when a Warp-hosted Cloud run has no +/// environment selected. `None` when not applicable. +pub fn empty_env_recommendation_message( + execution_mode: &RunAgentsExecutionMode, + app: &AppContext, +) -> Option { + let RunAgentsExecutionMode::Remote { + environment_id, + worker_host, + .. + } = execution_mode + else { + return None; + }; + if !environment_id.trim().is_empty() { + return None; + } + if !worker_host.eq_ignore_ascii_case(ORCHESTRATION_WARP_WORKER_HOST) { + return None; + } + let env_count = CloudAmbientAgentEnvironment::get_all(app).len(); + Some(if env_count > 0 { + "We recommend selecting an environment for cloud agents.".to_string() + } else { + "We recommend creating an environment for cloud agents.".to_string() + }) +} + +#[cfg(test)] +#[path = "validation_tests.rs"] +mod tests; diff --git a/app/src/ai/orchestration/validation_tests.rs b/app/src/ai/orchestration/validation_tests.rs new file mode 100644 index 00000000000..473db648107 --- /dev/null +++ b/app/src/ai/orchestration/validation_tests.rs @@ -0,0 +1,104 @@ +use ai::agent::action::RunAgentsExecutionMode; +use warpui::{App, AppContext, Entity}; + +use super::accept_disabled_reason_with_auth; +use crate::ai::orchestration::config_state::{AuthSecretSelection, OrchestrationConfigState}; + +/// Minimal entity used to borrow an `AppContext` inside `App::test`. +struct CtxProbe; + +impl Entity for CtxProbe { + type Event = (); +} + +/// Runs `f` with a plain `AppContext` (no singletons registered). +fn with_app_ctx(f: impl FnOnce(&AppContext) + 'static) { + App::test((), |mut app| async move { + let probe = app.add_model(|_| CtxProbe); + probe.update(&mut app, |_, ctx| f(ctx)); + }); +} + +fn state( + harness: &str, + mode: RunAgentsExecutionMode, + auth: AuthSecretSelection, +) -> OrchestrationConfigState { + let mut state = OrchestrationConfigState::from_run_agents_fields("auto", harness, &mode); + state.auth_secret_selection = auth; + state +} + +fn cloud() -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + } +} + +#[test] +fn accept_allowed_for_oz_local_and_cloud() { + with_app_ctx(|ctx| { + for mode in [RunAgentsExecutionMode::Local, cloud()] { + let state = state("oz", mode, AuthSecretSelection::Unset); + assert_eq!(accept_disabled_reason_with_auth(&state, ctx), None); + } + }); +} + +#[test] +fn accept_blocked_for_product_disabled_local_codex() { + with_app_ctx(|ctx| { + let state = state( + "codex", + RunAgentsExecutionMode::Local, + AuthSecretSelection::Unset, + ); + assert_eq!( + accept_disabled_reason_with_auth(&state, ctx), + Some("Local Codex child agents are temporarily disabled.".to_string()) + ); + }); +} + +#[test] +fn accept_blocked_for_opencode_cloud() { + with_app_ctx(|ctx| { + let state = state("opencode", cloud(), AuthSecretSelection::Unset); + let reason = accept_disabled_reason_with_auth(&state, ctx) + .expect("OpenCode + Cloud should block Accept"); + assert!(reason.contains("OpenCode")); + }); +} + +#[test] +fn accept_blocked_for_cloud_harness_with_unset_auth_secret() { + with_app_ctx(|ctx| { + for harness in ["claude", "codex"] { + for auth in [AuthSecretSelection::Unset, AuthSecretSelection::CreatingNew] { + let state = state(harness, cloud(), auth); + assert_eq!( + accept_disabled_reason_with_auth(&state, ctx), + Some("Select an API key for this harness to continue.".to_string()), + "Cloud + {harness} without an API key choice should block Accept" + ); + } + } + }); +} + +#[test] +fn accept_allowed_for_cloud_harness_with_named_or_inherited_auth() { + with_app_ctx(|ctx| { + for harness in ["claude", "codex"] { + for auth in [ + AuthSecretSelection::Named("my-key".to_string()), + AuthSecretSelection::Inherit, + ] { + let state = state(harness, cloud(), auth); + assert_eq!(accept_disabled_reason_with_auth(&state, ctx), None); + } + } + }); +} diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 8a38116e9ab..0035b6da92a 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -61,6 +61,14 @@ pub use crate::ai::conversation_export::{ }; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +pub use crate::ai::orchestration::{ + accept_disabled_reason_with_auth, auth_secret_selection_required, + empty_env_recommendation_message, harness_is_selectable, persist_environment_selection, + persist_host_selection, resolve_auth_secret_selection_for_harness, + resolve_default_environment_id, resolve_default_host_slug, resolve_recent_host_slug, + should_show_auth_secret_picker, AuthSecretSelection, OrchestrationConfigState, + OrchestrationEditState, ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, +}; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; pub use crate::banner::BannerState; diff --git a/specs/code-1822-edit-state/TECH.md b/specs/code-1822-edit-state/TECH.md new file mode 100644 index 00000000000..b3df47f7481 --- /dev/null +++ b/specs/code-1822-edit-state/TECH.md @@ -0,0 +1,120 @@ +# TECH: Frontend-neutral orchestration edit state + +## Context + +The GUI's multi-agent (`run_agents`) approval surfaces — the confirmation card and the +plan-card config block — share a run-wide edit state (model, harness, execution mode, +auth-secret selection) plus the transition/validation logic that keeps that state +consistent as the user edits it. Before this change, all of it lived inline in the GUI +controls module, entangled with `warpui` rendering types: + +- [`app/src/ai/blocklist/inline_action/orchestration_controls.rs @ f4bdf422`](https://github.com/warpdotdev/warp/blob/f4bdf4227/app/src/ai/blocklist/inline_action/orchestration_controls.rs) + — the config-fields struct (then named `OrchestrationEditState`, renamed here to + `OrchestrationConfigState`), `AuthSecretSelection`, catalog lookups + (`get_base_model_choices`, `is_model_in_filtered_choices`, `first_filtered_model_id`), + persistence helpers (`persist_*`), default resolution (`resolve_*`), validation + predicates (`should_show_auth_secret_picker`, `accept_disabled_reason_with_auth`, + `harness_is_selectable`), and the picker populate/sync functions, all in one GUI file. +- [`app/src/ai/blocklist/inline_action/run_agents_card_view.rs @ f4bdf422`](https://github.com/warpdotdev/warp/blob/f4bdf4227/app/src/ai/blocklist/inline_action/run_agents_card_view.rs) + — confirmation-card view; owns a per-view `saved_model_per_harness` map and calls the + controls helpers with closure-based fallbacks. +- [`app/src/ai/document/orchestration_config_block.rs @ f4bdf422`](https://github.com/warpdotdev/warp/blob/f4bdf4227/app/src/ai/document/orchestration_config_block.rs) + — plan-card config block; duplicates the same state-handling patterns. +- [`app/src/ai/blocklist/action_model/execute/run_agents.rs @ f4bdf422`](https://github.com/warpdotdev/warp/blob/f4bdf4227/app/src/ai/blocklist/action_model/execute/run_agents.rs) + — the `RunAgentsExecutor`; imports auth-secret execution helpers + (`can_execute_with_auth_secret`, `populate_default_auth_secret_for_execution`) from the + GUI controls module. + +A TUI orchestration card is planned. It must apply the exact same state math (harness +switching with per-harness model memory, Local/Cloud toggling, model revalidation against +live catalogs, auth-secret gating) without depending on GUI element types. That requires +splitting the frontend-neutral logic out of `orchestration_controls.rs`. + +## Proposed changes + +Create `app/src/ai/orchestration/`, a frontend-neutral domain module registered as +`pub(crate) mod orchestration;` in `app/src/ai/mod.rs`. Nothing in the module may depend +on `warpui::elements` or other GUI rendering types; it only reads/writes app singletons +through `AppContext`. + +- `config_state.rs` — `OrchestrationConfigState` (model_id, harness_type, execution_mode, + auth_secret_selection) and `AuthSecretSelection` (`Unset` / `Inherit` / `Named` / + `CreatingNew`), plus the pure conversions and mutations that were previously inline: + `from_run_agents_fields`, `from_orchestration_config`, `to_orchestration_config`, + `resolve_from_config`, `override_from_approved_config`, + `toggle_execution_mode_to_remote`, `sanitize_for_local_execution`, + `accept_disabled_reason`, `auth_secret_name`. +- `edit_state.rs` — edit-application logic with catalog access injected as + callbacks so cores are unit-testable without app singletons: + `OrchestrationConfigState::apply_execution_mode_change`, `apply_auth_secret_change`, + `revalidate_after_catalog_change`, and `OrchestrationEditState` — the config state plus + the per-harness `saved_model_per_harness` memory (previously a per-view field on each + card), with `apply_harness_change` handling save/restore/fallback of model selection + and auth re-resolution. +- `validation.rs` — shared predicates both frontends must gate through identically: + `harness_is_selectable` (excludes Gemini, + product-disabled and not-installed local harnesses), `should_show_auth_secret_picker`, + `auth_secret_selection_required`, `accept_disabled_reason_with_auth`, + `empty_env_recommendation_message`. +- `providers.rs` — `AppContext`-backed catalog lookups, default resolution, and + persistence: `get_base_model_choices`, `is_model_in_filtered_choices`, + `first_filtered_model_id`, `harness_save_key`, `resolve_default_environment_id`, + `resolve_default_host_slug`, `resolve_recent_host_slug`, + `resolve_auth_secret_selection_for_harness`, `persist_environment_selection`, + `persist_host_selection`, `persist_auth_secret_selection`, + `can_execute_with_auth_secret`, `populate_default_auth_secret_for_execution`, and the + `ORCHESTRATION_WARP_WORKER_HOST` / `ORCHESTRATION_ENV_NONE_LABEL` constants. + +GUI adaptation: + +- `orchestration_controls.rs` keeps everything rendering-related: picker chrome/styling, + `OrchestrationControlAction`, `OrchestrationPickerHandles`, the catalog-to-`MenuItem` + populate bodies (`populate_model_picker_for_harness`, `populate_harness_picker`, + `create_environment_picker`, `populate_environment_picker`, + `populate_auth_secret_picker_for_harness`, `populate_host_picker`, + `sync_picker_selections`), and render helpers. Its `apply_harness_change`, + `apply_execution_mode_change`, and `repopulate_all_pickers` become thin wrappers that + delegate the state math to `OrchestrationEditState` / `OrchestrationConfigState` and + then repopulate the affected pickers. A `pub use` shim re-exports the moved domain + names so existing GUI import paths keep compiling. The `DEFAULT_MODEL_LABEL` and + `AUTH_SECRET_INHERIT_LABEL` display strings stay defined in this GUI file. +- `run_agents_card_view.rs` and `orchestration_config_block.rs` hold an + `OrchestrationEditState` instead of a bare state plus ad-hoc model-memory map, and + call the thin wrappers with an `Option` fallback base-model id instead of a + closure. +- `execute/run_agents.rs` imports the auth-secret execution helpers from + `crate::ai::orchestration` instead of the GUI controls module. +- `app/src/tui_export.rs` exports the neutral surface for the upcoming TUI card: + `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, the + validation predicates, and the `persist_*` / `resolve_*` providers and + `ORCHESTRATION_*` constants. + +## Testing and validation + +- Unit tests colocated with the domain module per repo convention + (`config_state_tests.rs`, `edit_state_tests.rs`, `validation_tests.rs`): config + round-trips and overrides, execution-mode toggling (OpenCode→Oz reset, environment + pre-fill), harness-change model memory save/restore with injected catalog callbacks, + catalog-change revalidation (vanished model reset, deleted secret drop, `Unset` + re-seed), and the auth-secret Accept gate. +- Pre-existing GUI tests (`run_agents_card_view_tests.rs`, `run_agents_tests.rs`, + `orchestration_config_block` coverage) must pass unchanged apart from import paths; + the GUI tests that only exercised moved state math now live in the domain test files. +- Commands: `cargo check -p warp` and + `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents) + test(run_agents_card_view) + test(orchestration_config_block)'`, + plus `./script/format`. +- Manual: the confirmation card and plan-card config block behave identically to before — + harness switching preserves per-harness model choices, Local/Cloud toggling pre-fills + the default environment and resets invalid models, and Accept stays gated on + auth-secret selection for non-Oz cloud harnesses. + +## Parallelization + +Not beneficial: this is a single-PR extraction where the domain module, GUI adaptation, +and export surface are tightly coupled and must land atomically. + +## Follow-ups + +Later PRs build on this module: a plain-data option-snapshot layer (shared row/badge +snapshots consumed by both frontends' pickers), the TUI host/environment selector, and +the TUI orchestration card itself.