Fix Galaxy orchestration denial and controls
This commit is contained in:
@@ -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<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)>,
|
||||
|
||||
/// 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>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
|
||||
) -> Option<Box<dyn Element>> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ContextChipKind> for AgentToolbarItemKind {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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<A: OrchestrationControlAction> {
|
||||
/// auth-secret types.
|
||||
pub auth_secret_picker: Option<ViewHandle<Dropdown<A>>>,
|
||||
pub local_toggle: MouseStateHandle,
|
||||
pub cloud_toggle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
|
||||
@@ -372,7 +307,6 @@ impl<A: OrchestrationControlAction> Default for OrchestrationPickerHandles<A> {
|
||||
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<A: OrchestrationControlAction>(
|
||||
is_remote: bool,
|
||||
handles: &OrchestrationPickerHandles<A>,
|
||||
appearance: &Appearance,
|
||||
active_segment_bg: Option<Fill>,
|
||||
@@ -1822,27 +1755,18 @@ pub fn render_mode_toggle<A: OrchestrationControlAction>(
|
||||
|
||||
let local_segment = render_segment_button::<A>(
|
||||
"Local",
|
||||
!is_remote,
|
||||
true,
|
||||
A::execution_mode_toggled(false),
|
||||
handles.local_toggle.clone(),
|
||||
appearance,
|
||||
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 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Harness> for AIAgentHarness {
|
||||
Harness::from(*self) == *other
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "harness_display_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user