594 lines
18 KiB
Rust
594 lines
18 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode};
|
|
use ai::agent::action_result::StartAgentVersion;
|
|
use ai::skills::SkillReference;
|
|
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
|
use galaxyui::{App, SingletonEntity};
|
|
use settings::Setting;
|
|
|
|
use super::{
|
|
default_collapsible_state_for_orchestration_action,
|
|
default_collapsible_state_for_orchestration_message, received_message_collapsible_id,
|
|
user_avatar_info_for_conversation_creator, CollapsibleElementState, CollapsibleExpansionState,
|
|
UserAvatarInfo,
|
|
};
|
|
use crate::ai::agent::{AIAgentActionType, StartAgentExecutionMode};
|
|
use crate::ai::blocklist::action_model::{
|
|
compose_run_agents_child_prompt, run_agents_to_start_agent_mode,
|
|
};
|
|
use crate::auth::UserUid;
|
|
use crate::settings::{AISettings, OrchestrationMessageDisplayMode};
|
|
use crate::test_util::settings::initialize_settings_for_tests;
|
|
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
|
|
|
|
#[test]
|
|
fn reasoning_auto_collapses_when_user_has_not_manually_toggled() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
let mut state = CollapsibleElementState::default();
|
|
app.update(|ctx| {
|
|
state.finish_reasoning(ctx);
|
|
});
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn collapsed_initializer_starts_collapsed() {
|
|
let state = CollapsibleElementState::collapsed();
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_show_and_collapse_collapses_after_finish() {
|
|
let mut state = default_collapsible_state_for_orchestration_message(
|
|
OrchestrationMessageDisplayMode::ShowAndCollapse,
|
|
);
|
|
|
|
state.finish_orchestration_message(OrchestrationMessageDisplayMode::ShowAndCollapse);
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_always_show_stays_expanded_after_finish() {
|
|
let mut state = default_collapsible_state_for_orchestration_message(
|
|
OrchestrationMessageDisplayMode::AlwaysShow,
|
|
);
|
|
|
|
state.finish_orchestration_message(OrchestrationMessageDisplayMode::AlwaysShow);
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: true,
|
|
scroll_pinned_to_bottom: false
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_send_message_starts_collapsed() {
|
|
let state = default_collapsible_state_for_orchestration_action(
|
|
&AIAgentActionType::SendMessageToAgent {
|
|
addresses: vec!["child-agent".to_string()],
|
|
subject: "Status".to_string(),
|
|
message: "Body".to_string(),
|
|
},
|
|
OrchestrationMessageDisplayMode::AlwaysCollapse,
|
|
)
|
|
.expect("send-message actions should get a collapsible state");
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_start_agent_prompt_stays_expanded_for_all_message_modes() {
|
|
for display_mode in [
|
|
OrchestrationMessageDisplayMode::ShowAndCollapse,
|
|
OrchestrationMessageDisplayMode::AlwaysCollapse,
|
|
OrchestrationMessageDisplayMode::AlwaysShow,
|
|
] {
|
|
let state = default_collapsible_state_for_orchestration_action(
|
|
&AIAgentActionType::StartAgent {
|
|
version: StartAgentVersion::V1,
|
|
name: "child-agent".to_string(),
|
|
prompt: "Investigate".to_string(),
|
|
execution_mode: StartAgentExecutionMode::local_harness("claude-code".to_string()),
|
|
lifecycle_subscription: None,
|
|
},
|
|
display_mode,
|
|
)
|
|
.expect("start-agent actions should get a collapsible state");
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: false,
|
|
scroll_pinned_to_bottom: true
|
|
}
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn non_orchestration_actions_do_not_get_collapsible_state_defaults() {
|
|
assert!(default_collapsible_state_for_orchestration_action(
|
|
&AIAgentActionType::OpenCodeReview,
|
|
OrchestrationMessageDisplayMode::AlwaysCollapse,
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_show_and_collapse_starts_sent_messages_expanded() {
|
|
let state = default_collapsible_state_for_orchestration_action(
|
|
&AIAgentActionType::SendMessageToAgent {
|
|
addresses: vec!["child-agent".to_string()],
|
|
subject: "Status".to_string(),
|
|
message: "Body".to_string(),
|
|
},
|
|
OrchestrationMessageDisplayMode::ShowAndCollapse,
|
|
)
|
|
.expect("send-message actions should get a collapsible state");
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: false,
|
|
scroll_pinned_to_bottom: true
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_always_show_starts_sent_messages_expanded() {
|
|
let state = default_collapsible_state_for_orchestration_action(
|
|
&AIAgentActionType::SendMessageToAgent {
|
|
addresses: vec!["child-agent".to_string()],
|
|
subject: "Status".to_string(),
|
|
message: "Body".to_string(),
|
|
},
|
|
OrchestrationMessageDisplayMode::AlwaysShow,
|
|
)
|
|
.expect("send-message actions should get a collapsible state");
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: false,
|
|
scroll_pinned_to_bottom: true
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_received_messages_follow_initial_message_display_mode() {
|
|
let show_and_collapse = default_collapsible_state_for_orchestration_message(
|
|
OrchestrationMessageDisplayMode::ShowAndCollapse,
|
|
);
|
|
assert!(matches!(
|
|
show_and_collapse.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: false,
|
|
scroll_pinned_to_bottom: true
|
|
}
|
|
));
|
|
let collapsed = default_collapsible_state_for_orchestration_message(
|
|
OrchestrationMessageDisplayMode::AlwaysCollapse,
|
|
);
|
|
assert!(matches!(
|
|
collapsed.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
let expanded = default_collapsible_state_for_orchestration_message(
|
|
OrchestrationMessageDisplayMode::AlwaysShow,
|
|
);
|
|
|
|
assert!(matches!(
|
|
expanded.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: false,
|
|
scroll_pinned_to_bottom: true
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn always_show_thinking_stays_expanded_after_finish() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
|
settings
|
|
.thinking_display_mode
|
|
.set_value(crate::settings::ThinkingDisplayMode::AlwaysShow, ctx)
|
|
.unwrap();
|
|
});
|
|
|
|
let mut state = CollapsibleElementState::default();
|
|
app.update(|ctx| {
|
|
state.finish_reasoning(ctx);
|
|
});
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: true,
|
|
scroll_pinned_to_bottom: false
|
|
}
|
|
));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn manual_collapse_while_streaming_stays_collapsed_after_finish() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
let mut state = CollapsibleElementState::default();
|
|
|
|
state.toggle_expansion();
|
|
app.update(|ctx| {
|
|
state.finish_reasoning(ctx);
|
|
});
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Collapsed
|
|
));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn manual_reexpand_while_streaming_stays_expanded_after_finish() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
let mut state = CollapsibleElementState::default();
|
|
|
|
state.toggle_expansion();
|
|
state.toggle_expansion();
|
|
app.update(|ctx| {
|
|
state.finish_reasoning(ctx);
|
|
});
|
|
|
|
assert!(matches!(
|
|
state.expansion_state,
|
|
CollapsibleExpansionState::Expanded {
|
|
is_finished: true,
|
|
scroll_pinned_to_bottom: false
|
|
}
|
|
));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn received_message_collapsible_id_prefixes_row_ids() {
|
|
let first = received_message_collapsible_id("message-1");
|
|
let second = received_message_collapsible_id("message-2");
|
|
|
|
assert_eq!(&*first, "received-message:message-1");
|
|
assert_eq!(&*second, "received-message:message-2");
|
|
assert_ne!(first, second);
|
|
}
|
|
|
|
#[test]
|
|
fn user_avatar_info_prefers_conversation_creator_profile() {
|
|
App::test((), |app| async move {
|
|
let creator = UserProfileWithUID {
|
|
firebase_uid: UserUid::new("creator-uid"),
|
|
display_name: Some("Creator Name".to_string()),
|
|
email: "creator@example.com".to_string(),
|
|
photo_url: "https://example.com/creator.png".to_string(),
|
|
};
|
|
let fallback = UserAvatarInfo {
|
|
display_name: "Current User".to_string(),
|
|
profile_image_path: Some("https://example.com/current.png".to_string()),
|
|
};
|
|
|
|
app.read(|ctx| {
|
|
let avatar_info = user_avatar_info_for_conversation_creator(
|
|
Some(&creator),
|
|
Some("fallback-uid"),
|
|
fallback,
|
|
ctx,
|
|
);
|
|
|
|
assert_eq!(avatar_info.display_name, "Creator Name");
|
|
assert_eq!(
|
|
avatar_info.profile_image_path.as_deref(),
|
|
Some("https://example.com/creator.png")
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn user_avatar_info_uses_cached_profile_for_creator_uid() {
|
|
App::test((), |app| async move {
|
|
app.add_singleton_model(|_| {
|
|
UserProfiles::new(vec![UserProfileWithUID {
|
|
firebase_uid: UserUid::new("creator-uid"),
|
|
display_name: Some("Cached Creator".to_string()),
|
|
email: "cached@example.com".to_string(),
|
|
photo_url: "https://example.com/cached.png".to_string(),
|
|
}])
|
|
});
|
|
let fallback = UserAvatarInfo {
|
|
display_name: "Current User".to_string(),
|
|
profile_image_path: Some("https://example.com/current.png".to_string()),
|
|
};
|
|
|
|
app.read(|ctx| {
|
|
let avatar_info =
|
|
user_avatar_info_for_conversation_creator(None, Some("creator-uid"), fallback, ctx);
|
|
|
|
assert_eq!(avatar_info.display_name, "Cached Creator");
|
|
assert_eq!(
|
|
avatar_info.profile_image_path.as_deref(),
|
|
Some("https://example.com/cached.png")
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn compose_child_prompt_concatenates_when_both_non_empty() {
|
|
let composed = compose_run_agents_child_prompt("base", "do X");
|
|
assert_eq!(composed, "base\n\ndo X");
|
|
}
|
|
|
|
#[test]
|
|
fn compose_child_prompt_uses_base_only_when_per_agent_empty() {
|
|
let composed = compose_run_agents_child_prompt("base", "");
|
|
assert_eq!(composed, "base");
|
|
}
|
|
|
|
#[test]
|
|
fn compose_child_prompt_uses_per_agent_only_when_base_empty() {
|
|
let composed = compose_run_agents_child_prompt("", "do X");
|
|
assert_eq!(composed, "do X");
|
|
}
|
|
|
|
#[test]
|
|
fn compose_child_prompt_returns_empty_when_both_empty() {
|
|
let composed = compose_run_agents_child_prompt("", "");
|
|
assert_eq!(composed, "");
|
|
}
|
|
|
|
#[test]
|
|
fn compose_child_prompt_treats_whitespace_only_base_as_empty() {
|
|
let composed = compose_run_agents_child_prompt(" \n", "do X");
|
|
assert_eq!(composed, "do X");
|
|
}
|
|
|
|
fn agent_cfg() -> RunAgentsAgentRunConfig {
|
|
RunAgentsAgentRunConfig {
|
|
name: "child".to_string(),
|
|
prompt: "do X".to_string(),
|
|
title: "Child".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remote_arm_propagates_skills_into_skill_references() {
|
|
let skills = vec![
|
|
SkillReference::BundledSkillId("writing-pr-descriptions".to_string()),
|
|
SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from(
|
|
"/tmp/skill/SKILL.md",
|
|
))),
|
|
];
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Remote {
|
|
environment_id: "env-1".to_string(),
|
|
worker_host: "warp".to_string(),
|
|
computer_use_enabled: true,
|
|
},
|
|
"oz",
|
|
"auto",
|
|
&skills,
|
|
None,
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Remote+oz must convert");
|
|
let StartAgentExecutionMode::Remote {
|
|
skill_references,
|
|
environment_id,
|
|
worker_host,
|
|
harness_type,
|
|
model_id,
|
|
computer_use_enabled,
|
|
title,
|
|
auth_secret_name,
|
|
} = mode
|
|
else {
|
|
panic!("expected Remote start-agent mode");
|
|
};
|
|
assert_eq!(skill_references, skills);
|
|
assert_eq!(environment_id, "env-1");
|
|
assert_eq!(worker_host, "warp");
|
|
assert_eq!(harness_type, "oz");
|
|
assert_eq!(model_id, "auto");
|
|
assert!(computer_use_enabled);
|
|
assert_eq!(title, "Child");
|
|
assert_eq!(auth_secret_name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_arm_with_empty_skills_propagates_empty_vec() {
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Remote {
|
|
environment_id: "env-1".to_string(),
|
|
worker_host: "warp".to_string(),
|
|
computer_use_enabled: false,
|
|
},
|
|
"claude",
|
|
"auto",
|
|
&[],
|
|
None,
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Remote+claude must convert");
|
|
let StartAgentExecutionMode::Remote {
|
|
skill_references, ..
|
|
} = mode
|
|
else {
|
|
panic!("expected Remote start-agent mode");
|
|
};
|
|
assert!(skill_references.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn remote_arm_rejects_opencode() {
|
|
let err = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Remote {
|
|
environment_id: "env-1".to_string(),
|
|
worker_host: "warp".to_string(),
|
|
computer_use_enabled: false,
|
|
},
|
|
"opencode",
|
|
"auto",
|
|
&[],
|
|
None,
|
|
&agent_cfg(),
|
|
)
|
|
.expect_err("Remote+opencode must be rejected");
|
|
assert!(err.to_lowercase().contains("opencode"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_arm_rejects_disabled_codex() {
|
|
let err = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Local,
|
|
"codex",
|
|
"auto",
|
|
&[],
|
|
None,
|
|
&agent_cfg(),
|
|
)
|
|
.expect_err("Local+codex must be rejected while disabled");
|
|
assert_eq!(err, "Local Codex child agents are temporarily disabled.");
|
|
}
|
|
|
|
#[test]
|
|
fn local_arm_allows_claude() {
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Local,
|
|
"claude",
|
|
"auto",
|
|
&[],
|
|
None,
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Local+claude should convert");
|
|
assert!(matches!(
|
|
mode,
|
|
StartAgentExecutionMode::Local {
|
|
harness_type: Some(ref harness_type),
|
|
model_id: Some(ref model_id),
|
|
} if harness_type == "claude" && model_id == "auto"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_arm_propagates_claude_auth_secret_into_mode() {
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Remote {
|
|
environment_id: "env-1".to_string(),
|
|
worker_host: "warp".to_string(),
|
|
computer_use_enabled: false,
|
|
},
|
|
"claude",
|
|
"auto",
|
|
&[],
|
|
Some("my-claude-key"),
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Remote+claude must convert");
|
|
let StartAgentExecutionMode::Remote {
|
|
auth_secret_name, ..
|
|
} = mode
|
|
else {
|
|
panic!("expected Remote start-agent mode");
|
|
};
|
|
assert_eq!(auth_secret_name.as_deref(), Some("my-claude-key"));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_arm_filters_whitespace_auth_secret_name_to_none() {
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Remote {
|
|
environment_id: "env-1".to_string(),
|
|
worker_host: "warp".to_string(),
|
|
computer_use_enabled: false,
|
|
},
|
|
"codex",
|
|
"auto",
|
|
&[],
|
|
Some(" "),
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Remote+codex must convert");
|
|
let StartAgentExecutionMode::Remote {
|
|
auth_secret_name, ..
|
|
} = mode
|
|
else {
|
|
panic!("expected Remote start-agent mode");
|
|
};
|
|
assert_eq!(auth_secret_name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn local_arm_ignores_auth_secret_name() {
|
|
let mode = run_agents_to_start_agent_mode(
|
|
&RunAgentsExecutionMode::Local,
|
|
"claude",
|
|
"auto",
|
|
&[],
|
|
Some("my-claude-key"),
|
|
&agent_cfg(),
|
|
)
|
|
.expect("Local+claude should convert");
|
|
// Local children don't carry an auth_secret_name field.
|
|
assert!(matches!(mode, StartAgentExecutionMode::Local { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn should_show_agent_mode_ask_user_question_speedbump_defaults_to_true() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
|
assert!(*settings.should_show_agent_mode_ask_user_question_speedbump);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn should_show_agent_mode_ask_user_question_speedbump_round_trips_to_false() {
|
|
App::test((), |mut app| async move {
|
|
initialize_settings_for_tests(&mut app);
|
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
|
settings
|
|
.should_show_agent_mode_ask_user_question_speedbump
|
|
.set_value(false, ctx)
|
|
.unwrap();
|
|
});
|
|
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
|
assert!(!*settings.should_show_agent_mode_ask_user_question_speedbump);
|
|
});
|
|
});
|
|
}
|