diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 5f3bb970..65a80a72 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -246,6 +246,20 @@ fn permission_request_id(action_id: &AIAgentActionId) -> String { format!("permission:{action_id}") } +fn permission_denied_tool_event(action: &AIAgentAction) -> ToolEvent { + ToolEvent::PermissionResolved { + request_id: permission_request_id(&action.id), + call_id: action.id.to_string(), + decision: PermissionDecision::Denied { + reason: Some("Permission denied by the user.".to_string()), + }, + } +} + +fn should_emit_tool_completion(is_provider_owned: bool, permission_denied: bool) -> bool { + !is_provider_owned || !permission_denied +} + fn is_permission_denial(reason: CancellationReason, status: Option<&AIActionStatus>) -> bool { matches!(reason, CancellationReason::ManuallyCancelled) && matches!(status, Some(AIActionStatus::Blocked)) @@ -661,7 +675,7 @@ pub struct BlocklistAIActionModel { /// we can still order the results consistently. action_order: HashMap>, - /// Permission-card rejections that still need a correlated completion event. + /// Permission-card rejections whose cancelled action result must not emit a second provider event. denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>, /// Durable provider work identity for actions owned by an active provider run. @@ -1249,6 +1263,7 @@ impl BlocklistAIActionModel { ); return; }; + self.resolve_permission_denial(conversation_id, &action, ctx); let result = Arc::new(AIAgentActionResult { id: action.id, task_id: action.task_id, @@ -1832,6 +1847,36 @@ impl BlocklistAIActionModel { to_drain } + fn resolve_permission_denial( + &mut self, + conversation_id: AIConversationId, + pending_action: &AIAgentAction, + ctx: &mut ModelContext, + ) { + self.denied_permissions + .insert((conversation_id, pending_action.id.clone())); + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Warn, + "Tool permission resolved", + serde_json::json!({ + "event": "tool_permission_resolved", + "decision": "denied", + "conversation_id": conversation_id.to_string(), + "action_id": pending_action.id.to_string(), + "task_id": pending_action.task_id.to_string(), + "tool_name": action_tool_name(pending_action), + "permission_kind": format!("{:?}", permission_kind_for_action(&pending_action.action)), + }), + ); + ctx.emit(BlocklistAIActionEvent::ToolLifecycle { + action_id: pending_action.id.clone(), + execution_ref: self.provider_tool_execution_ref(conversation_id, &pending_action.id), + event: permission_denied_tool_event(pending_action), + }); + } + fn cancel_pending_action( &mut self, conversation_id: AIConversationId, @@ -1841,35 +1886,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { if permission_denied { - self.denied_permissions - .insert((conversation_id, pending_action.id.clone())); - #[cfg(not(target_family = "wasm"))] - log_tool_event( - ctx, - RemoteLogLevel::Warn, - "Tool permission resolved", - serde_json::json!({ - "event": "tool_permission_resolved", - "decision": "denied", - "conversation_id": conversation_id.to_string(), - "action_id": pending_action.id.to_string(), - "task_id": pending_action.task_id.to_string(), - "tool_name": action_tool_name(&pending_action), - "permission_kind": format!("{:?}", permission_kind_for_action(&pending_action.action)), - }), - ); - ctx.emit(BlocklistAIActionEvent::ToolLifecycle { - action_id: pending_action.id.clone(), - execution_ref: self - .provider_tool_execution_ref(conversation_id, &pending_action.id), - event: ToolEvent::PermissionResolved { - request_id: permission_request_id(&pending_action.id), - call_id: pending_action.id.to_string(), - decision: PermissionDecision::Denied { - reason: Some("Permission denied by the user.".to_string()), - }, - }, - }); + self.resolve_permission_denial(conversation_id, &pending_action, ctx); } if matches!( @@ -2103,7 +2120,7 @@ impl BlocklistAIActionModel { ); // Permission denial completes provider-owned calls when the permission decision is // applied, so emitting a second correlated completion would violate exactly-once delivery. - if execution_ref.is_none() || !permission_denied { + if should_emit_tool_completion(execution_ref.is_some(), permission_denied) { ctx.emit(BlocklistAIActionEvent::ToolLifecycle { action_id: action_result.id.clone(), execution_ref: execution_ref.clone(), 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 d8856a88..c3d823a1 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -695,6 +695,7 @@ fn prepare_request_for_execution( return Some(reason); } + normalize_request_for_local_execution(request); let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx); populate_default_auth_secret_for_execution(request, ctx); if let Some(reason) = @@ -885,6 +886,18 @@ fn populate_default_auth_secret_for_execution( default_auth_secret_name_for_harness(&request.harness_type, ctx); } +fn normalize_request_for_local_execution(request: &mut RunAgentsRequest) { + let edit_state = OrchestrationEditState::from_run_agents_fields( + &request.model_id, + &request.harness_type, + &request.execution_mode, + ); + request.model_id = edit_state.model_id; + request.harness_type = edit_state.harness_type; + request.execution_mode = RunAgentsExecutionMode::Local; + request.harness_auth_secret_name = None; +} + /// Unconditionally overrides run-wide fields on a `RunAgentsRequest` /// from the approved orchestration config, delegating to /// `OrchestrationEditState::override_from_approved_config`. @@ -908,6 +921,9 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> { if request.agent_run_configs.is_empty() { return Err("orchestrate: empty agent_run_configs".to_string()); } + if request.execution_mode.is_remote() { + return Err("Galaxy only supports local child-agent orchestration.".to_string()); + } let mut normalized_names = HashSet::new(); for config in &request.agent_run_configs { 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 7d8a72e0..04e105af 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 @@ -292,6 +292,7 @@ fn validate_request_rejects_blank_and_duplicate_agent_names() { let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else { panic!("expected run_agents action"); }; + normalize_request_for_local_execution(&mut request); request.agent_run_configs[0].name = " ".to_string(); assert_eq!( validate_request(&request), @@ -315,6 +316,7 @@ fn validate_request_allows_unique_sibling_names() { let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else { panic!("expected run_agents action"); }; + normalize_request_for_local_execution(&mut request); request.agent_run_configs.push(RunAgentsAgentRunConfig { name: "second-child".to_string(), prompt: "Do separate work".to_string(), @@ -324,6 +326,38 @@ fn validate_request_allows_unique_sibling_names() { assert_eq!(validate_request(&request), Ok(())); } +#[test] +fn local_normalization_clears_remote_only_fields_and_disabled_harness() { + let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("codex").action else { + panic!("expected run_agents action"); + }; + request.model_id = "gpt-5".to_string(); + request.harness_auth_secret_name = Some("remote-secret".to_string()); + + normalize_request_for_local_execution(&mut request); + + assert!(matches!( + request.execution_mode, + RunAgentsExecutionMode::Local + )); + assert_eq!(request.harness_type, "oz"); + assert_eq!(request.model_id, ""); + assert_eq!(request.harness_auth_secret_name, None); + assert_eq!(validate_request(&request), Ok(())); +} + +#[test] +fn validate_request_rejects_remote_dispatch() { + let AIAgentActionType::RunAgents(request) = remote_run_agents_action("oz").action else { + panic!("expected run_agents action"); + }; + + assert_eq!( + validate_request(&request), + Err("Galaxy only supports local child-agent orchestration.".to_string()) + ); +} + fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState { initialize_settings_for_tests_with_mode(app, mode, false); let global_resource_handles = GlobalResourceHandles::mock(app); @@ -486,7 +520,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() { } #[test] -fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() { +fn approved_remote_plan_is_normalized_and_can_autoexecute_locally() { App::test((), |mut app| async move { let state = initialize_run_agents_test(&mut app, ExecutionMode::App); persist_plan_config_with_harness( @@ -508,7 +542,7 @@ fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_sec ) }); - assert!(!should_autoexecute); + assert!(should_autoexecute); }); } @@ -782,7 +816,7 @@ fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() { } #[test] -fn execute_denies_remote_non_warp_harness_without_default_auth_secret() { +fn execute_normalizes_remote_non_oz_harness_without_requiring_remote_auth() { App::test((), |mut app| async move { let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let action = remote_run_agents_action("codex"); @@ -799,21 +833,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() { .into() }); - let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied { - reason, - })) = execution - else { - panic!("expected synchronous run_agents denial"); - }; - assert_eq!( - reason, - "Cloud child agents using this harness require an API key before they can run." - ); + assert!(matches!(execution, AnyActionExecution::Async { .. })); }); } #[test] -fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() { +fn normalized_remote_non_oz_harness_autoexecutes_with_always_allow() { App::test((), |mut app| async move { let state = initialize_run_agents_test(&mut app, ExecutionMode::App); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); @@ -834,7 +859,7 @@ fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_def } #[test] -fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() { +fn normalized_remote_non_oz_harness_ignores_default_auth_secret() { App::test((), |mut app| async move { let state = initialize_run_agents_test(&mut app, ExecutionMode::App); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); @@ -856,7 +881,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() { } #[test] -fn should_autoexecute_remote_warp_harness_without_default_auth_secret() { +fn normalized_remote_oz_harness_autoexecutes_without_default_auth_secret() { App::test((), |mut app| async move { let state = initialize_run_agents_test(&mut app, ExecutionMode::App); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); diff --git a/app/src/ai/blocklist/action_model_tests.rs b/app/src/ai/blocklist/action_model_tests.rs index 469f8ddb..6e2f286b 100644 --- a/app/src/ai/blocklist/action_model_tests.rs +++ b/app/src/ai/blocklist/action_model_tests.rs @@ -258,6 +258,36 @@ fn action_permission_kinds_match_the_safety_boundary() { ); } +#[test] +fn denied_permission_event_resolves_the_pending_call() { + let action = action("call-1"); + + let ToolEvent::PermissionResolved { + request_id, + call_id, + decision, + } = permission_denied_tool_event(&action) + else { + panic!("expected a permission resolution event"); + }; + + assert_eq!(request_id, "permission:call-1"); + assert_eq!(call_id, "call-1"); + assert_eq!( + decision, + PermissionDecision::Denied { + reason: Some("Permission denied by the user.".to_string()), + } + ); +} + +#[test] +fn provider_owned_denial_suppresses_duplicate_completion() { + assert!(!should_emit_tool_completion(true, true)); + assert!(should_emit_tool_completion(true, false)); + assert!(should_emit_tool_completion(false, true)); +} + #[test] fn only_rejecting_a_blocked_action_is_a_permission_denial() { assert!(is_permission_denial( diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index e89bde22..3216fa5b 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -1405,6 +1405,7 @@ impl AgentInputFooter { ) -> Option> { if !item.available_in().is_available_for_cli() || !item.available_to_session_viewer(shared_status, false) + || !item.is_available(app) { return None; } @@ -2016,6 +2017,7 @@ impl AgentInputFooter { }); if !item.available_in().is_available_for_agent_view() || !item.available_to_session_viewer(shared_status, is_cloud_mode) + || !item.is_available(app) { return None; } diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs index eeac3d6d..ac5b1d4d 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs @@ -178,6 +178,8 @@ impl AgentToolbarItemKind { pub fn is_available(&self, app: &warpui::AppContext) -> bool { match self { Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app), + // Retain the enum variant so existing toolbar settings still deserialize. + Self::ShareSession => false, _ => true, } } @@ -215,11 +217,6 @@ impl AgentToolbarItemKind { Self::ContextWindowUsage, Self::ModelSelector, ]; - if FeatureFlag::CreatingSharedSessions.is_enabled() - && FeatureFlag::HOARemoteControl.is_enabled() - { - items.push(Self::ShareSession); - } if FeatureFlag::OzHandoff.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled() && cfg!(all(feature = "local_fs", not(target_family = "wasm"))) @@ -247,11 +244,6 @@ impl AgentToolbarItemKind { if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() { items.push(Self::FastForwardToggle); } - if FeatureFlag::CreatingSharedSessions.is_enabled() - && FeatureFlag::HOARemoteControl.is_enabled() - { - items.push(Self::ShareSession); - } if FeatureFlag::OzHandoff.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled() && cfg!(all(feature = "local_fs", not(target_family = "wasm"))) @@ -322,3 +314,7 @@ impl From for AgentToolbarItemKind { Self::ContextChip(kind) } } + +#[cfg(test)] +#[path = "toolbar_item_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item_tests.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item_tests.rs new file mode 100644 index 00000000..54fbdf2f --- /dev/null +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item_tests.rs @@ -0,0 +1,15 @@ +use super::AgentToolbarItemKind; + +#[test] +fn legacy_share_session_setting_remains_deserializable() { + let item: AgentToolbarItemKind = + serde_json::from_str("\"ShareSession\"").expect("legacy setting should deserialize"); + + assert_eq!(item, AgentToolbarItemKind::ShareSession); +} + +#[test] +fn share_session_is_not_offered_by_defaults_or_configurator() { + assert!(!AgentToolbarItemKind::default_right().contains(&AgentToolbarItemKind::ShareSession)); + assert!(!AgentToolbarItemKind::all_available().contains(&AgentToolbarItemKind::ShareSession)); +} diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 86eb6bd7..97a5b29a 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -175,30 +175,32 @@ impl OrchestrationEditState { self.model_id.clear(); } } + pub fn from_run_agents_fields( model_id: &str, harness_type: &str, execution_mode: &RunAgentsExecutionMode, ) -> Self { - Self { + let execution_mode = match execution_mode { + RunAgentsExecutionMode::Local | RunAgentsExecutionMode::Remote { .. } => { + RunAgentsExecutionMode::Local + } + }; + let mut state = Self { model_id: model_id.to_string(), harness_type: harness_type.to_string(), - execution_mode: execution_mode.clone(), + execution_mode, auth_secret_selection: AuthSecretSelection::Unset, - } + }; + state.sanitize_for_local_execution(); + state } 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, - }, + OrchestrationExecutionMode::Local | OrchestrationExecutionMode::Remote { .. } => { + RunAgentsExecutionMode::Local + } }; let mut state = Self { model_id: config.model_id.clone(), @@ -206,30 +208,17 @@ impl OrchestrationEditState { execution_mode, auth_secret_selection: AuthSecretSelection::Unset, }; - if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { - state.sanitize_for_local_execution(); - } + state.sanitize_for_local_execution(); state } - /// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching - /// to Cloud (unsupported combination). + /// Galaxy only supports local child agents, so any mode selection is normalized to Local. 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(); + log::warn!("Ignoring remote orchestration selection because Galaxy is local-only"); } + self.execution_mode = RunAgentsExecutionMode::Local; + self.sanitize_for_local_execution(); } pub fn set_environment_id(&mut self, environment_id: String) { @@ -251,27 +240,17 @@ impl OrchestrationEditState { } /// 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 { .. } => { + Some("Galaxy only supports local child-agent orchestration.") } - 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. + /// Fills empty model and harness fields from the approved config while keeping execution local. 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(); @@ -279,67 +258,24 @@ impl OrchestrationEditState { 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(); - } + 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. + /// Applies the approved model and harness while keeping execution local. 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; - } + self.execution_mode = RunAgentsExecutionMode::Local; + self.sanitize_for_local_execution(); } - /// Converts to a native `OrchestrationConfig` for storage / match. + /// Converts to a local-only 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, + execution_mode: OrchestrationExecutionMode::Local, } } } @@ -360,7 +296,6 @@ pub struct OrchestrationPickerHandles { /// auth-secret types. pub auth_secret_picker: Option>>, pub local_toggle: MouseStateHandle, - pub cloud_toggle: MouseStateHandle, } impl Default for OrchestrationPickerHandles { @@ -372,7 +307,6 @@ impl Default for OrchestrationPickerHandles { host_picker: None, auth_secret_picker: None, local_toggle: MouseStateHandle::default(), - cloud_toggle: MouseStateHandle::default(), } } } @@ -1805,7 +1739,6 @@ impl Element for AdaptivePickerRow { // ── Render helpers ────────────────────────────────────────────────── pub fn render_mode_toggle( - is_remote: bool, handles: &OrchestrationPickerHandles, appearance: &Appearance, active_segment_bg: Option, @@ -1822,27 +1755,18 @@ pub fn render_mode_toggle( let local_segment = render_segment_button::( "Local", - !is_remote, + true, A::execution_mode_toggled(false), handles.local_toggle.clone(), appearance, active_segment_bg, ); - let cloud_segment = render_segment_button::( - "Cloud", - is_remote, - A::execution_mode_toggled(true), - handles.cloud_toggle.clone(), - appearance, - active_segment_bg, - ); let segment_outer_bg = galaxy_core::ui::theme::color::internal_colors::fg_overlay_2(theme); let segments_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_main_axis_alignment(MainAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Max) - .with_child(Expanded::new(1.0, cloud_segment).finish()) .with_child(Expanded::new(1.0, local_segment).finish()) .finish(); let segmented_control = Container::new(segments_row) diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs index 96c47fb9..c97fb238 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs @@ -6,18 +6,6 @@ use super::{ 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(), @@ -26,10 +14,44 @@ fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { } } +fn remote_config(harness_type: &str, model_id: &str) -> OrchestrationConfig { + OrchestrationConfig { + model_id: model_id.to_string(), + harness_type: harness_type.to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + }, + } +} + +fn remote_mode() -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: true, + } +} + #[test] -fn from_orchestration_config_preserves_local_claude() { +fn run_agents_remote_mode_is_normalized_to_local() { + let state = OrchestrationEditState::from_run_agents_fields("sonnet", "claude", &remote_mode()); + + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "sonnet"); + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Local + )); + assert!(should_show_harness_picker(&state)); + assert!(!should_show_auth_secret_picker(&state)); +} + +#[test] +fn remote_orchestration_config_is_normalized_to_local() { let state = - OrchestrationEditState::from_orchestration_config(&local_config("claude", "sonnet")); + OrchestrationEditState::from_orchestration_config(&remote_config("claude", "sonnet")); + assert_eq!(state.harness_type, "claude"); assert_eq!(state.model_id, "sonnet"); assert!(matches!( @@ -39,66 +61,24 @@ fn from_orchestration_config_preserves_local_claude() { } #[test] -fn harness_picker_stays_visible_for_local_mode() { - let state = OrchestrationEditState::from_run_agents_fields( +fn remote_toggle_remains_local() { + let mut 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, - }, - ); + state.toggle_execution_mode_to_remote(true); - 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" + RunAgentsExecutionMode::Local )); } #[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); +fn local_normalization_sanitizes_disabled_harnesses() { + let state = OrchestrationEditState::from_run_agents_fields("gpt-5", "codex", &remote_mode()); assert_eq!(state.harness_type, "oz"); assert_eq!(state.model_id, ""); @@ -109,18 +89,11 @@ fn toggle_to_local_sanitizes_disabled_codex() { } #[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, - }, - ); +fn resolve_from_remote_config_inherits_fields_but_stays_local() { + let mut state = + OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); - state.toggle_execution_mode_to_remote(false); + state.resolve_from_config(&remote_config("claude", "sonnet")); assert_eq!(state.harness_type, "claude"); assert_eq!(state.model_id, "sonnet"); @@ -131,27 +104,21 @@ fn toggle_to_local_preserves_claude() { } #[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); -} +fn approved_remote_config_override_stays_local() { + let mut state = OrchestrationEditState::from_run_agents_fields("auto", "oz", &remote_mode()); -#[test] -fn resolve_from_config_preserves_local_claude() { - let mut state = - OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); + state.override_from_approved_config(&remote_config("claude", "sonnet")); - 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 )); + assert!(matches!( + state.to_orchestration_config().execution_mode, + OrchestrationExecutionMode::Local + )); } #[test] @@ -163,32 +130,29 @@ fn resolve_from_config_sanitizes_disabled_local_codex() { assert_eq!(state.harness_type, "oz"); assert_eq!(state.model_id, ""); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); + assert_eq!(state.accept_disabled_reason(), None); } #[test] -fn select_create_new_auth_secret_marks_creating_new_from_named() { - let mut state = remote_claude_state(); +fn local_mode_does_not_expose_managed_auth_secret() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "sonnet", + "claude", + &RunAgentsExecutionMode::Local, + ); 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)); + 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(); +fn selecting_create_auth_secret_remains_a_distinct_state() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "sonnet", + "claude", + &RunAgentsExecutionMode::Local, + ); state.auth_secret_selection = AuthSecretSelection::Inherit; state.select_create_new_auth_secret(); 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 64df1ebc..e043fa3c 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 @@ -139,7 +139,7 @@ impl RunAgentsEditState { 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(), + execution_mode: RunAgentsExecutionMode::Local, 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), @@ -1490,7 +1490,6 @@ fn render_editor( column.add_child( Container::new(oc::render_mode_toggle( - state.orch.execution_mode.is_remote(), &handles.pickers, appearance, None, 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 be8876b5..a9ef589d 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 @@ -57,30 +57,20 @@ fn make_edit_state_with_orch_fields( } #[test] -fn local_to_cloud_initializes_remote_with_empty_environment() { +fn remote_toggle_remains_local() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); + + state.orch.toggle_execution_mode_to_remote(true); + assert!(matches!( state.orch.execution_mode, RunAgentsExecutionMode::Local )); - - state.orch.toggle_execution_mode_to_remote(true); - let RunAgentsExecutionMode::Remote { - environment_id, - worker_host, - computer_use_enabled, - } = state.orch.execution_mode - else { - panic!("expected Remote after toggle"); - }; - assert_eq!(environment_id, ""); - assert_eq!(worker_host, "warp"); - assert!(!computer_use_enabled); } #[test] -fn cloud_to_local_drops_environment() { +fn legacy_remote_request_normalizes_to_local() { let mut state = RunAgentsEditState::from_request(&make_request( "oz", RunAgentsExecutionMode::Remote { @@ -97,15 +87,21 @@ fn cloud_to_local_drops_environment() { } #[test] -fn local_to_cloud_resets_opencode_to_oz() { +fn remote_toggle_preserves_supported_local_harness() { 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"); + + assert_eq!(state.orch.harness_type, "opencode"); + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); } #[test] -fn cloud_without_env_no_longer_disables_accept() { +fn legacy_remote_request_without_environment_allows_local_acceptance() { let state = RunAgentsEditState::from_request(&make_request( "oz", RunAgentsExecutionMode::Remote { @@ -114,15 +110,15 @@ fn cloud_without_env_no_longer_disables_accept() { computer_use_enabled: false, }, )); - assert!( - state.orch.accept_disabled_reason().is_none(), - "Cloud without env should NOT disable Accept (soft recommendation only)" - ); + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); + assert!(state.orch.accept_disabled_reason().is_none()); } #[test] -fn cloud_with_opencode_disables_accept() { - // Bypass the toggle helper to test the validation gate directly. +fn legacy_remote_opencode_request_is_normalized_and_allowed_locally() { let state = RunAgentsEditState::from_request(&make_request( "opencode", RunAgentsExecutionMode::Remote { @@ -131,9 +127,12 @@ fn cloud_with_opencode_disables_accept() { computer_use_enabled: false, }, )); - let reason = state.orch.accept_disabled_reason(); - assert!(reason.is_some(), "Cloud + OpenCode should disable Accept"); - assert!(reason.unwrap().contains("OpenCode")); + + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); + assert_eq!(state.orch.accept_disabled_reason(), None); } #[test] @@ -149,12 +148,11 @@ fn local_with_any_harness_does_not_disable_accept() { } #[test] -fn local_with_disabled_codex_disables_accept() { +fn local_with_disabled_codex_is_sanitized() { let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local); - assert_eq!( - state.orch.accept_disabled_reason(), - Some("Local Codex child agents are temporarily disabled.") - ); + + assert_eq!(state.orch.harness_type, "oz"); + assert_eq!(state.orch.accept_disabled_reason(), None); } #[test] @@ -168,7 +166,7 @@ fn from_request_sanitizes_disabled_local_harness_to_oz() { } #[test] -fn cloud_with_env_and_non_opencode_harness_allows_accept() { +fn legacy_remote_harnesses_normalize_and_allow_local_acceptance() { for harness in ["oz", "claude", "gemini"] { let state = RunAgentsEditState::from_request(&make_request( harness, @@ -178,9 +176,13 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() { computer_use_enabled: false, }, )); + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); assert!( state.orch.accept_disabled_reason().is_none(), - "Cloud + env + {harness} should allow Accept" + "normalized local + {harness} should allow Accept" ); } } @@ -197,7 +199,7 @@ fn set_environment_id_no_op_in_local_mode() { } #[test] -fn set_environment_id_updates_remote() { +fn set_environment_id_is_ignored_for_normalized_remote_request() { let mut state = RunAgentsEditState::from_request(&make_request( "oz", RunAgentsExecutionMode::Remote { @@ -206,15 +208,17 @@ 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 { - panic!("expected Remote"); - }; - assert_eq!(environment_id, "new-env"); + + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); } #[test] -fn to_request_round_trips_request_fields() { +fn to_request_preserves_fields_but_normalizes_execution_to_local() { let mut req = make_request_with_skills( "claude", RunAgentsExecutionMode::Remote { @@ -232,11 +236,15 @@ fn to_request_round_trips_request_fields() { req.plan_id = "plan-1".to_string(); let state = RunAgentsEditState::from_request(&req); let round_tripped = state.to_request(); + assert_eq!(round_tripped.summary, req.summary); assert_eq!(round_tripped.base_prompt, req.base_prompt); assert_eq!(round_tripped.model_id, req.model_id); assert_eq!(round_tripped.harness_type, req.harness_type); - assert_eq!(round_tripped.execution_mode, req.execution_mode); + assert!(matches!( + round_tripped.execution_mode, + RunAgentsExecutionMode::Local + )); assert_eq!(round_tripped.agent_run_configs, req.agent_run_configs); assert_eq!(round_tripped.skills, req.skills); assert_eq!(round_tripped.plan_id, req.plan_id); @@ -414,34 +422,27 @@ mod override_from_approved_config_tests { #[test] fn overrides_even_when_request_has_values() { - let mut state = RunAgentsEditState::from_request(&make_request( - "claude", - RunAgentsExecutionMode::Local, - )); + let mut state = + RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state .orch - .override_from_approved_config(&local_config("gpt-5", "codex")); - assert_eq!(state.orch.model_id, "gpt-5"); - assert_eq!(state.orch.harness_type, "codex"); + .override_from_approved_config(&local_config("sonnet", "claude")); + assert_eq!(state.orch.model_id, "sonnet"); + assert_eq!(state.orch.harness_type, "claude"); } #[test] - fn overrides_local_to_remote() { + fn remote_config_override_stays_local() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state .orch .override_from_approved_config(&remote_config("auto", "oz", "env-1")); - let RunAgentsExecutionMode::Remote { - environment_id, - worker_host, - .. - } = &state.orch.execution_mode - else { - panic!("expected Remote after override"); - }; - assert_eq!(environment_id, "env-1"); - assert_eq!(worker_host, "warp"); + + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); } #[test] @@ -464,7 +465,7 @@ mod override_from_approved_config_tests { } #[test] - fn preserves_computer_use_when_both_remote() { + fn remote_request_and_remote_override_drop_computer_use() { let mut state = RunAgentsEditState::from_request(&make_request( "oz", RunAgentsExecutionMode::Remote { @@ -476,57 +477,29 @@ mod override_from_approved_config_tests { state .orch .override_from_approved_config(&remote_config("auto", "oz", "new-env")); - let RunAgentsExecutionMode::Remote { - environment_id, - computer_use_enabled, - .. - } = &state.orch.execution_mode - else { - panic!("expected Remote"); - }; - assert_eq!(environment_id, "new-env", "env should come from config"); - assert!( - *computer_use_enabled, - "computer_use_enabled should be preserved from original request" - ); + + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); } #[test] - fn does_not_carry_computer_use_from_local_to_remote() { + fn approved_local_disabled_harness_is_sanitized() { let mut state = RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); state .orch - .override_from_approved_config(&remote_config("auto", "oz", "env-1")); - let RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - } = &state.orch.execution_mode - else { - panic!("expected Remote"); - }; - assert!( - !*computer_use_enabled, - "computer_use_enabled should default to false when original was Local" - ); - } + .override_from_approved_config(&local_config("gpt-5", "codex")); - #[test] - fn approved_local_disabled_harness_reports_disabled_reason_after_override() { - let mut state = - RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); - state - .orch - .override_from_approved_config(&local_config("auto", "codex")); - assert_eq!( - state.orch.accept_disabled_reason(), - Some("Local Codex child agents are temporarily disabled.") - ); + assert_eq!(state.orch.harness_type, "oz"); + assert_eq!(state.orch.model_id, ""); + assert_eq!(state.orch.accept_disabled_reason(), None); } } #[test] -fn local_to_cloud_idempotent_when_already_remote() { +fn remote_toggle_is_idempotently_local() { let mut state = RunAgentsEditState::from_request(&make_request( "oz", RunAgentsExecutionMode::Remote { @@ -535,21 +508,11 @@ fn local_to_cloud_idempotent_when_already_remote() { computer_use_enabled: true, }, )); + state.orch.toggle_execution_mode_to_remote(true); - let RunAgentsExecutionMode::Remote { - environment_id, - computer_use_enabled, - .. - } = state.orch.execution_mode - else { - panic!("expected Remote"); - }; - assert_eq!( - environment_id, "env-1", - "toggle to Remote when already Remote should not clobber env" - ); - assert!( - computer_use_enabled, - "toggle to Remote when already Remote should not clobber computer_use" - ); + + assert!(matches!( + state.orch.execution_mode, + RunAgentsExecutionMode::Local + )); } diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index cc53c8f1..e8aa1426 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -658,12 +658,11 @@ impl View for OrchestrationConfigBlockView { // Expanded controls if self.details_expanded { - // Cloud / Local mode toggle (full width) + // Galaxy orchestration is local-only. let active_seg_bg = galaxy_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.pickers, appearance, Some(active_seg_bg), diff --git a/app/src/ai/harness_display.rs b/app/src/ai/harness_display.rs index 2c287618..712edfca 100644 --- a/app/src/ai/harness_display.rs +++ b/app/src/ai/harness_display.rs @@ -17,7 +17,7 @@ use crate::ui_components::icons::Icon; /// User-visible display name for a [`Harness`]. pub fn display_name(harness: Harness) -> &'static str { match harness { - Harness::Oz => "Warp", + Harness::Oz => "Galaxy", Harness::Claude => "Claude Code", Harness::OpenCode => "OpenCode", Harness::Gemini => "Gemini CLI", @@ -94,3 +94,7 @@ impl PartialEq for AIAgentHarness { Harness::from(*self) == *other } } + +#[cfg(test)] +#[path = "harness_display_tests.rs"] +mod tests; diff --git a/app/src/ai/harness_display_tests.rs b/app/src/ai/harness_display_tests.rs new file mode 100644 index 00000000..386c7278 --- /dev/null +++ b/app/src/ai/harness_display_tests.rs @@ -0,0 +1,8 @@ +use galaxy_cli::agent::Harness; + +use super::display_name; + +#[test] +fn oz_harness_uses_galaxy_display_name() { + assert_eq!(display_name(Harness::Oz), "Galaxy"); +}