Fix Galaxy orchestration denial and controls

This commit is contained in:
2026-08-15 08:37:57 -05:00
parent 642cb6adc1
commit 2730712179
14 changed files with 348 additions and 386 deletions
+48 -31
View File
@@ -246,6 +246,20 @@ fn permission_request_id(action_id: &AIAgentActionId) -> String {
format!("permission:{action_id}") 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 { fn is_permission_denial(reason: CancellationReason, status: Option<&AIActionStatus>) -> bool {
matches!(reason, CancellationReason::ManuallyCancelled) matches!(reason, CancellationReason::ManuallyCancelled)
&& matches!(status, Some(AIActionStatus::Blocked)) && matches!(status, Some(AIActionStatus::Blocked))
@@ -661,7 +675,7 @@ pub struct BlocklistAIActionModel {
/// we can still order the results consistently. /// we can still order the results consistently.
action_order: HashMap<AIConversationId, HashMap<AIAgentActionId, usize>>, action_order: HashMap<AIConversationId, HashMap<AIAgentActionId, usize>>,
/// 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)>, denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
/// Durable provider work identity for actions owned by an active provider run. /// Durable provider work identity for actions owned by an active provider run.
@@ -1249,6 +1263,7 @@ impl BlocklistAIActionModel {
); );
return; return;
}; };
self.resolve_permission_denial(conversation_id, &action, ctx);
let result = Arc::new(AIAgentActionResult { let result = Arc::new(AIAgentActionResult {
id: action.id, id: action.id,
task_id: action.task_id, task_id: action.task_id,
@@ -1832,6 +1847,36 @@ impl BlocklistAIActionModel {
to_drain to_drain
} }
fn resolve_permission_denial(
&mut self,
conversation_id: AIConversationId,
pending_action: &AIAgentAction,
ctx: &mut ModelContext<Self>,
) {
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( fn cancel_pending_action(
&mut self, &mut self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -1841,35 +1886,7 @@ impl BlocklistAIActionModel {
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
if permission_denied { if permission_denied {
self.denied_permissions self.resolve_permission_denial(conversation_id, &pending_action, ctx);
.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()),
},
},
});
} }
if matches!( if matches!(
@@ -2103,7 +2120,7 @@ impl BlocklistAIActionModel {
); );
// Permission denial completes provider-owned calls when the permission decision is // Permission denial completes provider-owned calls when the permission decision is
// applied, so emitting a second correlated completion would violate exactly-once delivery. // 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 { ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_result.id.clone(), action_id: action_result.id.clone(),
execution_ref: execution_ref.clone(), execution_ref: execution_ref.clone(),
@@ -695,6 +695,7 @@ fn prepare_request_for_execution(
return Some(reason); return Some(reason);
} }
normalize_request_for_local_execution(request);
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx); let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx); populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) = 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); 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` /// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to /// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`. /// `OrchestrationEditState::override_from_approved_config`.
@@ -908,6 +921,9 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() { if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string()); 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(); let mut normalized_names = HashSet::new();
for config in &request.agent_run_configs { for config in &request.agent_run_configs {
@@ -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 { let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action"); panic!("expected run_agents action");
}; };
normalize_request_for_local_execution(&mut request);
request.agent_run_configs[0].name = " ".to_string(); request.agent_run_configs[0].name = " ".to_string();
assert_eq!( assert_eq!(
validate_request(&request), 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 { let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action"); panic!("expected run_agents action");
}; };
normalize_request_for_local_execution(&mut request);
request.agent_run_configs.push(RunAgentsAgentRunConfig { request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "second-child".to_string(), name: "second-child".to_string(),
prompt: "Do separate work".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(())); 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 { fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false); initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app); let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -486,7 +520,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() {
} }
#[test] #[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 { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config_with_harness( 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] #[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 { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("codex"); let action = remote_run_agents_action("codex");
@@ -799,21 +833,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
.into() .into()
}); });
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied { assert!(matches!(execution, AnyActionExecution::Async { .. }));
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."
);
}); });
} }
#[test] #[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 { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); 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] #[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 { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -856,7 +881,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
} }
#[test] #[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 { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -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] #[test]
fn only_rejecting_a_blocked_action_is_a_permission_denial() { fn only_rejecting_a_blocked_action_is_a_permission_denial() {
assert!(is_permission_denial( assert!(is_permission_denial(
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
) -> Option<Box<dyn Element>> { ) -> Option<Box<dyn Element>> {
if !item.available_in().is_available_for_cli() if !item.available_in().is_available_for_cli()
|| !item.available_to_session_viewer(shared_status, false) || !item.available_to_session_viewer(shared_status, false)
|| !item.is_available(app)
{ {
return None; return None;
} }
@@ -2016,6 +2017,7 @@ impl AgentInputFooter {
}); });
if !item.available_in().is_available_for_agent_view() if !item.available_in().is_available_for_agent_view()
|| !item.available_to_session_viewer(shared_status, is_cloud_mode) || !item.available_to_session_viewer(shared_status, is_cloud_mode)
|| !item.is_available(app)
{ {
return None; return None;
} }
@@ -178,6 +178,8 @@ impl AgentToolbarItemKind {
pub fn is_available(&self, app: &warpui::AppContext) -> bool { pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self { match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app), 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, _ => true,
} }
} }
@@ -215,11 +217,6 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage, Self::ContextWindowUsage,
Self::ModelSelector, Self::ModelSelector,
]; ];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled() if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm"))) && cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -247,11 +244,6 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() { if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle); items.push(Self::FastForwardToggle);
} }
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled() if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm"))) && cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -322,3 +314,7 @@ impl From<ContextChipKind> for AgentToolbarItemKind {
Self::ContextChip(kind) Self::ContextChip(kind)
} }
} }
#[cfg(test)]
#[path = "toolbar_item_tests.rs"]
mod tests;
@@ -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));
}
@@ -175,30 +175,32 @@ impl OrchestrationEditState {
self.model_id.clear(); self.model_id.clear();
} }
} }
pub fn from_run_agents_fields( pub fn from_run_agents_fields(
model_id: &str, model_id: &str,
harness_type: &str, harness_type: &str,
execution_mode: &RunAgentsExecutionMode, execution_mode: &RunAgentsExecutionMode,
) -> Self { ) -> Self {
Self { let execution_mode = match execution_mode {
RunAgentsExecutionMode::Local | RunAgentsExecutionMode::Remote { .. } => {
RunAgentsExecutionMode::Local
}
};
let mut state = Self {
model_id: model_id.to_string(), model_id: model_id.to_string(),
harness_type: harness_type.to_string(), harness_type: harness_type.to_string(),
execution_mode: execution_mode.clone(), execution_mode,
auth_secret_selection: AuthSecretSelection::Unset, auth_secret_selection: AuthSecretSelection::Unset,
} };
state.sanitize_for_local_execution();
state
} }
pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self { pub fn from_orchestration_config(config: &OrchestrationConfig) -> Self {
let execution_mode = match &config.execution_mode { let execution_mode = match &config.execution_mode {
OrchestrationExecutionMode::Local => RunAgentsExecutionMode::Local, OrchestrationExecutionMode::Local | OrchestrationExecutionMode::Remote { .. } => {
OrchestrationExecutionMode::Remote { RunAgentsExecutionMode::Local
environment_id, }
worker_host,
} => RunAgentsExecutionMode::Remote {
environment_id: environment_id.clone(),
worker_host: worker_host.clone(),
computer_use_enabled: false,
},
}; };
let mut state = Self { let mut state = Self {
model_id: config.model_id.clone(), model_id: config.model_id.clone(),
@@ -206,30 +208,17 @@ impl OrchestrationEditState {
execution_mode, execution_mode,
auth_secret_selection: AuthSecretSelection::Unset, auth_secret_selection: AuthSecretSelection::Unset,
}; };
if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { state.sanitize_for_local_execution();
state.sanitize_for_local_execution();
}
state state
} }
/// Toggle Local ↔ Cloud. Resets OpenCode to Oz when switching /// Galaxy only supports local child agents, so any mode selection is normalized to Local.
/// to Cloud (unsupported combination).
pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) { pub fn toggle_execution_mode_to_remote(&mut self, is_remote: bool) {
if is_remote { if is_remote {
if self.harness_type.eq_ignore_ascii_case("opencode") { log::warn!("Ignoring remote orchestration selection because Galaxy is local-only");
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();
} }
self.execution_mode = RunAgentsExecutionMode::Local;
self.sanitize_for_local_execution();
} }
pub fn set_environment_id(&mut self, environment_id: String) { 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. /// 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> { pub fn accept_disabled_reason(&self) -> Option<&'static str> {
match &self.execution_mode { match &self.execution_mode {
RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type) RunAgentsExecutionMode::Local => Harness::parse_local_child_harness(&self.harness_type)
.and_then(local_harness_product_disabled_message), .and_then(local_harness_product_disabled_message),
RunAgentsExecutionMode::Remote { .. } RunAgentsExecutionMode::Remote { .. } => {
if self.harness_type.eq_ignore_ascii_case("opencode") => Some("Galaxy only supports local child-agent orchestration.")
{
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. /// Fills empty model and harness fields from the approved config while keeping execution local.
/// 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) { pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) {
if self.harness_type.is_empty() && !config.harness_type.is_empty() { if self.harness_type.is_empty() && !config.harness_type.is_empty() {
self.harness_type = config.harness_type.clone(); self.harness_type = config.harness_type.clone();
@@ -279,67 +258,24 @@ impl OrchestrationEditState {
if self.model_id.is_empty() && !config.model_id.is_empty() { if self.model_id.is_empty() && !config.model_id.is_empty() {
self.model_id = config.model_id.clone(); self.model_id = config.model_id.clone();
} }
if !self.execution_mode.is_remote() && config.execution_mode.is_remote() { self.execution_mode = RunAgentsExecutionMode::Local;
self.execution_mode = Self::from_orchestration_config(config).execution_mode; self.sanitize_for_local_execution();
}
if matches!(self.execution_mode, RunAgentsExecutionMode::Local) {
self.sanitize_for_local_execution();
}
} }
/// Unconditionally overrides model, harness, and execution mode /// Applies the approved model and harness while keeping execution local.
/// 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) { pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) {
self.model_id = config.model_id.clone(); self.model_id = config.model_id.clone();
self.harness_type = config.harness_type.clone(); self.harness_type = config.harness_type.clone();
self.execution_mode = RunAgentsExecutionMode::Local;
let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { self.sanitize_for_local_execution();
(
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. /// Converts to a local-only native `OrchestrationConfig` for storage / match.
pub fn to_orchestration_config(&self) -> OrchestrationConfig { 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 { OrchestrationConfig {
model_id: self.model_id.clone(), model_id: self.model_id.clone(),
harness_type: self.harness_type.clone(), harness_type: self.harness_type.clone(),
execution_mode, execution_mode: OrchestrationExecutionMode::Local,
} }
} }
} }
@@ -360,7 +296,6 @@ pub struct OrchestrationPickerHandles<A: OrchestrationControlAction> {
/// auth-secret types. /// auth-secret types.
pub auth_secret_picker: Option<ViewHandle<Dropdown<A>>>, pub auth_secret_picker: Option<ViewHandle<Dropdown<A>>>,
pub local_toggle: MouseStateHandle, pub local_toggle: MouseStateHandle,
pub cloud_toggle: MouseStateHandle,
} }
impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> { impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
@@ -372,7 +307,6 @@ impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
host_picker: None, host_picker: None,
auth_secret_picker: None, auth_secret_picker: None,
local_toggle: MouseStateHandle::default(), local_toggle: MouseStateHandle::default(),
cloud_toggle: MouseStateHandle::default(),
} }
} }
} }
@@ -1805,7 +1739,6 @@ impl Element for AdaptivePickerRow {
// ── Render helpers ────────────────────────────────────────────────── // ── Render helpers ──────────────────────────────────────────────────
pub fn render_mode_toggle<A: OrchestrationControlAction>( pub fn render_mode_toggle<A: OrchestrationControlAction>(
is_remote: bool,
handles: &OrchestrationPickerHandles<A>, handles: &OrchestrationPickerHandles<A>,
appearance: &Appearance, appearance: &Appearance,
active_segment_bg: Option<Fill>, active_segment_bg: Option<Fill>,
@@ -1822,27 +1755,18 @@ pub fn render_mode_toggle<A: OrchestrationControlAction>(
let local_segment = render_segment_button::<A>( let local_segment = render_segment_button::<A>(
"Local", "Local",
!is_remote, true,
A::execution_mode_toggled(false), A::execution_mode_toggled(false),
handles.local_toggle.clone(), handles.local_toggle.clone(),
appearance, appearance,
active_segment_bg, active_segment_bg,
); );
let cloud_segment = render_segment_button::<A>(
"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 segment_outer_bg = galaxy_core::ui::theme::color::internal_colors::fg_overlay_2(theme);
let segments_row = Flex::row() let segments_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_alignment(MainAxisAlignment::Start) .with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max) .with_main_axis_size(MainAxisSize::Max)
.with_child(Expanded::new(1.0, cloud_segment).finish())
.with_child(Expanded::new(1.0, local_segment).finish()) .with_child(Expanded::new(1.0, local_segment).finish())
.finish(); .finish();
let segmented_control = Container::new(segments_row) let segmented_control = Container::new(segments_row)
@@ -6,18 +6,6 @@ use super::{
OrchestrationEditState, 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 { fn local_config(harness_type: &str, model_id: &str) -> OrchestrationConfig {
OrchestrationConfig { OrchestrationConfig {
model_id: model_id.to_string(), 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] #[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 = 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.harness_type, "claude");
assert_eq!(state.model_id, "sonnet"); assert_eq!(state.model_id, "sonnet");
assert!(matches!( assert!(matches!(
@@ -39,66 +61,24 @@ fn from_orchestration_config_preserves_local_claude() {
} }
#[test] #[test]
fn harness_picker_stays_visible_for_local_mode() { fn remote_toggle_remains_local() {
let state = OrchestrationEditState::from_run_agents_fields( let mut state = OrchestrationEditState::from_run_agents_fields(
"auto", "auto",
"oz", "oz",
&RunAgentsExecutionMode::Local, &RunAgentsExecutionMode::Local,
); );
assert!(should_show_harness_picker(&state));
}
#[test] state.toggle_execution_mode_to_remote(true);
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!( assert!(matches!(
state.execution_mode, state.execution_mode,
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Local
ref environment_id,
ref worker_host,
computer_use_enabled: false,
} if environment_id == "env-1" && worker_host == "warp"
)); ));
} }
#[test] #[test]
fn toggle_to_local_sanitizes_disabled_codex() { fn local_normalization_sanitizes_disabled_harnesses() {
let mut state = OrchestrationEditState::from_run_agents_fields( let state = OrchestrationEditState::from_run_agents_fields("gpt-5", "codex", &remote_mode());
"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.harness_type, "oz");
assert_eq!(state.model_id, ""); assert_eq!(state.model_id, "");
@@ -109,18 +89,11 @@ fn toggle_to_local_sanitizes_disabled_codex() {
} }
#[test] #[test]
fn toggle_to_local_preserves_claude() { fn resolve_from_remote_config_inherits_fields_but_stays_local() {
let mut state = OrchestrationEditState::from_run_agents_fields( let mut state =
"sonnet", OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local);
"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); state.resolve_from_config(&remote_config("claude", "sonnet"));
assert_eq!(state.harness_type, "claude"); assert_eq!(state.harness_type, "claude");
assert_eq!(state.model_id, "sonnet"); assert_eq!(state.model_id, "sonnet");
@@ -131,27 +104,21 @@ fn toggle_to_local_preserves_claude() {
} }
#[test] #[test]
fn accept_disabled_reason_allows_local_claude_product() { fn approved_remote_config_override_stays_local() {
let state = OrchestrationEditState::from_run_agents_fields( let mut state = OrchestrationEditState::from_run_agents_fields("auto", "oz", &remote_mode());
"auto",
"claude",
&RunAgentsExecutionMode::Local,
);
assert_eq!(state.accept_disabled_reason(), None);
}
#[test] state.override_from_approved_config(&remote_config("claude", "sonnet"));
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.harness_type, "claude");
assert_eq!(state.model_id, "sonnet"); assert_eq!(state.model_id, "sonnet");
assert!(matches!( assert!(matches!(
state.execution_mode, state.execution_mode,
RunAgentsExecutionMode::Local RunAgentsExecutionMode::Local
)); ));
assert!(matches!(
state.to_orchestration_config().execution_mode,
OrchestrationExecutionMode::Local
));
} }
#[test] #[test]
@@ -163,32 +130,29 @@ fn resolve_from_config_sanitizes_disabled_local_codex() {
assert_eq!(state.harness_type, "oz"); assert_eq!(state.harness_type, "oz");
assert_eq!(state.model_id, ""); assert_eq!(state.model_id, "");
assert!(matches!( assert_eq!(state.accept_disabled_reason(), None);
state.execution_mode,
RunAgentsExecutionMode::Local
));
} }
#[test] #[test]
fn select_create_new_auth_secret_marks_creating_new_from_named() { fn local_mode_does_not_expose_managed_auth_secret() {
let mut state = remote_claude_state(); let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Named("my-key".to_string()); 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_eq!(state.auth_secret_name(), None);
assert!(should_show_auth_secret_picker(&state)); assert!(!should_show_auth_secret_picker(&state));
} }
#[test] #[test]
fn select_create_new_auth_secret_marks_creating_new_from_inherit() { fn selecting_create_auth_secret_remains_a_distinct_state() {
let mut state = remote_claude_state(); let mut state = OrchestrationEditState::from_run_agents_fields(
"sonnet",
"claude",
&RunAgentsExecutionMode::Local,
);
state.auth_secret_selection = AuthSecretSelection::Inherit; state.auth_secret_selection = AuthSecretSelection::Inherit;
state.select_create_new_auth_secret(); state.select_create_new_auth_secret();
@@ -139,7 +139,7 @@ impl RunAgentsEditState {
skills: self.skills.clone(), skills: self.skills.clone(),
model_id: self.orch.model_id.clone(), model_id: self.orch.model_id.clone(),
harness_type: self.orch.harness_type.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(), agent_run_configs: self.agent_run_configs.clone(),
plan_id: self.plan_id.clone(), plan_id: self.plan_id.clone(),
harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string), harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string),
@@ -1490,7 +1490,6 @@ fn render_editor(
column.add_child( column.add_child(
Container::new(oc::render_mode_toggle( Container::new(oc::render_mode_toggle(
state.orch.execution_mode.is_remote(),
&handles.pickers, &handles.pickers,
appearance, appearance,
None, None,
@@ -57,30 +57,20 @@ fn make_edit_state_with_orch_fields(
} }
#[test] #[test]
fn local_to_cloud_initializes_remote_with_empty_environment() { fn remote_toggle_remains_local() {
let mut state = let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true);
assert!(matches!( assert!(matches!(
state.orch.execution_mode, state.orch.execution_mode,
RunAgentsExecutionMode::Local 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] #[test]
fn cloud_to_local_drops_environment() { fn legacy_remote_request_normalizes_to_local() {
let mut state = RunAgentsEditState::from_request(&make_request( let mut state = RunAgentsEditState::from_request(&make_request(
"oz", "oz",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -97,15 +87,21 @@ fn cloud_to_local_drops_environment() {
} }
#[test] #[test]
fn local_to_cloud_resets_opencode_to_oz() { fn remote_toggle_preserves_supported_local_harness() {
let mut state = let mut state =
RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local)); RunAgentsEditState::from_request(&make_request("opencode", RunAgentsExecutionMode::Local));
state.orch.toggle_execution_mode_to_remote(true); 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] #[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( let state = RunAgentsEditState::from_request(&make_request(
"oz", "oz",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -114,15 +110,15 @@ fn cloud_without_env_no_longer_disables_accept() {
computer_use_enabled: false, computer_use_enabled: false,
}, },
)); ));
assert!( assert!(matches!(
state.orch.accept_disabled_reason().is_none(), state.orch.execution_mode,
"Cloud without env should NOT disable Accept (soft recommendation only)" RunAgentsExecutionMode::Local
); ));
assert!(state.orch.accept_disabled_reason().is_none());
} }
#[test] #[test]
fn cloud_with_opencode_disables_accept() { fn legacy_remote_opencode_request_is_normalized_and_allowed_locally() {
// Bypass the toggle helper to test the validation gate directly.
let state = RunAgentsEditState::from_request(&make_request( let state = RunAgentsEditState::from_request(&make_request(
"opencode", "opencode",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -131,9 +127,12 @@ fn cloud_with_opencode_disables_accept() {
computer_use_enabled: false, computer_use_enabled: false,
}, },
)); ));
let reason = state.orch.accept_disabled_reason();
assert!(reason.is_some(), "Cloud + OpenCode should disable Accept"); assert!(matches!(
assert!(reason.unwrap().contains("OpenCode")); state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(state.orch.accept_disabled_reason(), None);
} }
#[test] #[test]
@@ -149,12 +148,11 @@ fn local_with_any_harness_does_not_disable_accept() {
} }
#[test] #[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); let state = make_edit_state_with_orch_fields("codex", RunAgentsExecutionMode::Local);
assert_eq!(
state.orch.accept_disabled_reason(), assert_eq!(state.orch.harness_type, "oz");
Some("Local Codex child agents are temporarily disabled.") assert_eq!(state.orch.accept_disabled_reason(), None);
);
} }
#[test] #[test]
@@ -168,7 +166,7 @@ fn from_request_sanitizes_disabled_local_harness_to_oz() {
} }
#[test] #[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"] { for harness in ["oz", "claude", "gemini"] {
let state = RunAgentsEditState::from_request(&make_request( let state = RunAgentsEditState::from_request(&make_request(
harness, harness,
@@ -178,9 +176,13 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() {
computer_use_enabled: false, computer_use_enabled: false,
}, },
)); ));
assert!(matches!(
state.orch.execution_mode,
RunAgentsExecutionMode::Local
));
assert!( assert!(
state.orch.accept_disabled_reason().is_none(), 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] #[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( let mut state = RunAgentsEditState::from_request(&make_request(
"oz", "oz",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -206,15 +208,17 @@ fn set_environment_id_updates_remote() {
computer_use_enabled: false, computer_use_enabled: false,
}, },
)); ));
state.orch.set_environment_id("new-env".to_string()); state.orch.set_environment_id("new-env".to_string());
let RunAgentsExecutionMode::Remote { environment_id, .. } = state.orch.execution_mode else {
panic!("expected Remote"); assert!(matches!(
}; state.orch.execution_mode,
assert_eq!(environment_id, "new-env"); RunAgentsExecutionMode::Local
));
} }
#[test] #[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( let mut req = make_request_with_skills(
"claude", "claude",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -232,11 +236,15 @@ fn to_request_round_trips_request_fields() {
req.plan_id = "plan-1".to_string(); req.plan_id = "plan-1".to_string();
let state = RunAgentsEditState::from_request(&req); let state = RunAgentsEditState::from_request(&req);
let round_tripped = state.to_request(); let round_tripped = state.to_request();
assert_eq!(round_tripped.summary, req.summary); assert_eq!(round_tripped.summary, req.summary);
assert_eq!(round_tripped.base_prompt, req.base_prompt); assert_eq!(round_tripped.base_prompt, req.base_prompt);
assert_eq!(round_tripped.model_id, req.model_id); assert_eq!(round_tripped.model_id, req.model_id);
assert_eq!(round_tripped.harness_type, req.harness_type); 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.agent_run_configs, req.agent_run_configs);
assert_eq!(round_tripped.skills, req.skills); assert_eq!(round_tripped.skills, req.skills);
assert_eq!(round_tripped.plan_id, req.plan_id); assert_eq!(round_tripped.plan_id, req.plan_id);
@@ -414,34 +422,27 @@ mod override_from_approved_config_tests {
#[test] #[test]
fn overrides_even_when_request_has_values() { fn overrides_even_when_request_has_values() {
let mut state = RunAgentsEditState::from_request(&make_request( let mut state =
"claude", RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
RunAgentsExecutionMode::Local,
));
state state
.orch .orch
.override_from_approved_config(&local_config("gpt-5", "codex")); .override_from_approved_config(&local_config("sonnet", "claude"));
assert_eq!(state.orch.model_id, "gpt-5"); assert_eq!(state.orch.model_id, "sonnet");
assert_eq!(state.orch.harness_type, "codex"); assert_eq!(state.orch.harness_type, "claude");
} }
#[test] #[test]
fn overrides_local_to_remote() { fn remote_config_override_stays_local() {
let mut state = let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state state
.orch .orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1")); .override_from_approved_config(&remote_config("auto", "oz", "env-1"));
let RunAgentsExecutionMode::Remote {
environment_id, assert!(matches!(
worker_host, state.orch.execution_mode,
.. RunAgentsExecutionMode::Local
} = &state.orch.execution_mode ));
else {
panic!("expected Remote after override");
};
assert_eq!(environment_id, "env-1");
assert_eq!(worker_host, "warp");
} }
#[test] #[test]
@@ -464,7 +465,7 @@ mod override_from_approved_config_tests {
} }
#[test] #[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( let mut state = RunAgentsEditState::from_request(&make_request(
"oz", "oz",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -476,57 +477,29 @@ mod override_from_approved_config_tests {
state state
.orch .orch
.override_from_approved_config(&remote_config("auto", "oz", "new-env")); .override_from_approved_config(&remote_config("auto", "oz", "new-env"));
let RunAgentsExecutionMode::Remote {
environment_id, assert!(matches!(
computer_use_enabled, state.orch.execution_mode,
.. RunAgentsExecutionMode::Local
} = &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"
);
} }
#[test] #[test]
fn does_not_carry_computer_use_from_local_to_remote() { fn approved_local_disabled_harness_is_sanitized() {
let mut state = let mut state =
RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local));
state state
.orch .orch
.override_from_approved_config(&remote_config("auto", "oz", "env-1")); .override_from_approved_config(&local_config("gpt-5", "codex"));
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"
);
}
#[test] assert_eq!(state.orch.harness_type, "oz");
fn approved_local_disabled_harness_reports_disabled_reason_after_override() { assert_eq!(state.orch.model_id, "");
let mut state = assert_eq!(state.orch.accept_disabled_reason(), None);
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.")
);
} }
} }
#[test] #[test]
fn local_to_cloud_idempotent_when_already_remote() { fn remote_toggle_is_idempotently_local() {
let mut state = RunAgentsEditState::from_request(&make_request( let mut state = RunAgentsEditState::from_request(&make_request(
"oz", "oz",
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -535,21 +508,11 @@ fn local_to_cloud_idempotent_when_already_remote() {
computer_use_enabled: true, computer_use_enabled: true,
}, },
)); ));
state.orch.toggle_execution_mode_to_remote(true); state.orch.toggle_execution_mode_to_remote(true);
let RunAgentsExecutionMode::Remote {
environment_id, assert!(matches!(
computer_use_enabled, state.orch.execution_mode,
.. RunAgentsExecutionMode::Local
} = 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"
);
} }
@@ -658,12 +658,11 @@ impl View for OrchestrationConfigBlockView {
// Expanded controls // Expanded controls
if self.details_expanded { if self.details_expanded {
// Cloud / Local mode toggle (full width) // Galaxy orchestration is local-only.
let active_seg_bg = let active_seg_bg =
galaxy_core::ui::theme::color::internal_colors::accent_overlay_2(theme); galaxy_core::ui::theme::color::internal_colors::accent_overlay_2(theme);
column.add_child( column.add_child(
Container::new(oc::render_mode_toggle( Container::new(oc::render_mode_toggle(
self.edit_state.execution_mode.is_remote(),
&self.pickers, &self.pickers,
appearance, appearance,
Some(active_seg_bg), Some(active_seg_bg),
+5 -1
View File
@@ -17,7 +17,7 @@ use crate::ui_components::icons::Icon;
/// User-visible display name for a [`Harness`]. /// User-visible display name for a [`Harness`].
pub fn display_name(harness: Harness) -> &'static str { pub fn display_name(harness: Harness) -> &'static str {
match harness { match harness {
Harness::Oz => "Warp", Harness::Oz => "Galaxy",
Harness::Claude => "Claude Code", Harness::Claude => "Claude Code",
Harness::OpenCode => "OpenCode", Harness::OpenCode => "OpenCode",
Harness::Gemini => "Gemini CLI", Harness::Gemini => "Gemini CLI",
@@ -94,3 +94,7 @@ impl PartialEq<Harness> for AIAgentHarness {
Harness::from(*self) == *other Harness::from(*self) == *other
} }
} }
#[cfg(test)]
#[path = "harness_display_tests.rs"]
mod tests;
+8
View File
@@ -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");
}