Merge remote-tracking branch 'ryserve/master'
# Conflicts: # app/src/ai/llms.rs
This commit is contained in:
@@ -28,12 +28,28 @@ pub(crate) fn prepare_direct_provider_params(
|
||||
.clone()
|
||||
.unwrap_or_else(|| get_supported_tools(params));
|
||||
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
|
||||
let ask_user_question_enabled = FeatureFlag::AskUserQuestion.is_enabled()
|
||||
&& params.ask_user_question_enabled
|
||||
&& matches!(
|
||||
params.permission_mode,
|
||||
crate::ai::execution_profiles::PermissionMode::AskFirst
|
||||
| crate::ai::execution_profiles::PermissionMode::AllowAll
|
||||
);
|
||||
supported_tools.retain(|tool| {
|
||||
*tool != api::ToolType::AskUserQuestion || ask_user_question_enabled
|
||||
});
|
||||
// Computer use controls this machine, even when the terminal is connected elsewhere.
|
||||
// Only expose the initial request here; the dedicated profile owns subsequent actions.
|
||||
supported_tools.retain(|tool| *tool != api::ToolType::UseComputer
|
||||
&& (*tool != api::ToolType::RequestComputerUse || params.computer_use_enabled));
|
||||
let mut supported_cli_agent_tools =
|
||||
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
|
||||
remove_orchestration_tools_if_disabled(
|
||||
&mut supported_cli_agent_tools,
|
||||
params.orchestration_enabled,
|
||||
);
|
||||
supported_cli_agent_tools.retain(|tool| !matches!(tool, api::ToolType::UseComputer | api::ToolType::RequestComputerUse));
|
||||
supported_cli_agent_tools.retain(|tool| *tool != api::ToolType::AskUserQuestion);
|
||||
if params.permission_mode == crate::ai::execution_profiles::PermissionMode::ReadOnly {
|
||||
let permitted = |tool: &api::ToolType| is_read_only_tool(tool)
|
||||
|| (!params.command_allowlist.is_empty() && matches!(tool, api::ToolType::RunShellCommand | api::ToolType::ReadShellCommandOutput | api::ToolType::WriteToLongRunningShellCommand));
|
||||
@@ -73,6 +89,9 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
||||
api::ToolType::CreateDocuments,
|
||||
api::ToolType::EditDocuments,
|
||||
];
|
||||
if params.computer_use_enabled {
|
||||
supported_tools.push(api::ToolType::RequestComputerUse);
|
||||
}
|
||||
|
||||
if params.orchestration_enabled {
|
||||
supported_tools.push(api::ToolType::Subagent);
|
||||
|
||||
@@ -91,6 +91,37 @@ fn supported_tools_include_plan_document_capabilities() {
|
||||
assert!(supported_tools.contains(&api::ToolType::EditDocuments));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_is_explicitly_enabled_and_kept_out_of_command_monitor_tools() {
|
||||
let mut params = request_params_with_ask_user_question_enabled(false);
|
||||
params.computer_use_enabled = true;
|
||||
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
|
||||
assert!(normal.contains(&api::ToolType::RequestComputerUse));
|
||||
assert!(!normal.contains(&api::ToolType::UseComputer));
|
||||
assert!(!cli.contains(&api::ToolType::RequestComputerUse));
|
||||
assert!(!cli.contains(&api::ToolType::UseComputer));
|
||||
|
||||
params.computer_use_enabled = false;
|
||||
let (normal, _) = super::prepare_direct_provider_params(&mut params);
|
||||
assert!(!normal.contains(&api::ToolType::RequestComputerUse));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_never_expands_explicit_tool_overrides_or_read_only_permissions() {
|
||||
let mut params = request_params_with_ask_user_question_enabled(false);
|
||||
params.computer_use_enabled = true;
|
||||
params.supported_tools_override = Some(Vec::new());
|
||||
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
|
||||
assert!(normal.is_empty());
|
||||
assert!(cli.is_empty());
|
||||
|
||||
params.permission_mode = crate::ai::execution_profiles::PermissionMode::ReadOnly;
|
||||
params.supported_tools_override = Some(vec![api::ToolType::RequestComputerUse, api::ToolType::UseComputer]);
|
||||
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
|
||||
assert!(normal.is_empty());
|
||||
assert!(cli.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
|
||||
let params = request_params_with_ask_user_question_enabled(false);
|
||||
@@ -160,9 +191,7 @@ fn supported_tools_omits_ask_user_question_when_disabled() {
|
||||
|
||||
#[test]
|
||||
fn supported_tools_includes_ask_user_question_when_enabled_and_feature_flag_is_enabled() {
|
||||
if !FeatureFlag::AskUserQuestion.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let _flag = FeatureFlag::AskUserQuestion.override_enabled(true);
|
||||
|
||||
let params = request_params_with_ask_user_question_enabled(true);
|
||||
let supported_tools = get_supported_tools(¶ms);
|
||||
@@ -170,6 +199,47 @@ fn supported_tools_includes_ask_user_question_when_enabled_and_feature_flag_is_e
|
||||
assert!(supported_tools.contains(&api::ToolType::AskUserQuestion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_overrides_respect_mode_capability_and_feature_without_enabling_cli_pauses() {
|
||||
use crate::ai::execution_profiles::PermissionMode;
|
||||
|
||||
for (mode, enabled, feature_enabled, expected) in [
|
||||
(PermissionMode::AskFirst, true, true, true),
|
||||
(PermissionMode::AllowAll, true, true, true),
|
||||
(PermissionMode::AllowAll, false, true, false),
|
||||
(PermissionMode::AllowAll, true, false, false),
|
||||
(PermissionMode::GalaxyDecides, true, true, false),
|
||||
(PermissionMode::ReadOnly, true, true, false),
|
||||
] {
|
||||
let _flag = FeatureFlag::AskUserQuestion.override_enabled(feature_enabled);
|
||||
let mut params = request_params_with_ask_user_question_enabled(enabled);
|
||||
params.permission_mode = mode;
|
||||
params.supported_tools_override = Some(vec![api::ToolType::AskUserQuestion]);
|
||||
|
||||
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
|
||||
|
||||
assert_eq!(
|
||||
normal.contains(&api::ToolType::AskUserQuestion),
|
||||
expected,
|
||||
"{mode:?}"
|
||||
);
|
||||
assert!(!cli.contains(&api::ToolType::AskUserQuestion), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_availability_does_not_expand_empty_tool_overrides() {
|
||||
let _flag = FeatureFlag::AskUserQuestion.override_enabled(true);
|
||||
let mut params = request_params_with_ask_user_question_enabled(true);
|
||||
params.permission_mode = crate::ai::execution_profiles::PermissionMode::AllowAll;
|
||||
params.supported_tools_override = Some(Vec::new());
|
||||
|
||||
let (normal, cli) = super::prepare_direct_provider_params(&mut params);
|
||||
|
||||
assert!(normal.is_empty());
|
||||
assert!(cli.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_supported_tools_include_search_codebase_when_connected_and_feature_flag_is_enabled() {
|
||||
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
|
||||
|
||||
@@ -754,7 +754,7 @@ pub enum RenderableAIError {
|
||||
|
||||
impl RenderableAIError {
|
||||
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
|
||||
"Warp lost connection while receiving the agent response. This is usually temporary.";
|
||||
"Galaxy lost connection while receiving the agent response. This is usually temporary.";
|
||||
/// User-facing message shown when an agent-issued command exits the shell.
|
||||
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str =
|
||||
"The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
|
||||
@@ -905,9 +905,9 @@ impl Display for RenderableAIError {
|
||||
}
|
||||
}
|
||||
Self::ServerOverloaded => {
|
||||
write!(f, "Warp is currently overloaded. Please try again later.")
|
||||
write!(f, "The AI service is currently overloaded. Please try again later.")
|
||||
}
|
||||
Self::InternalWarpError => write!(f, "Internal Warp error."),
|
||||
Self::InternalWarpError => write!(f, "Internal Galaxy error."),
|
||||
Self::ContextWindowExceeded(message) => {
|
||||
write!(f, "Context window exceeded: {message}")
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ fn transient_network_error_includes_user_facing_message_and_debug_details() {
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
rendered.starts_with(
|
||||
"Warp lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: "
|
||||
"Galaxy lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: "
|
||||
),
|
||||
"unexpected rendering: {rendered}"
|
||||
);
|
||||
|
||||
@@ -8,6 +8,81 @@ use crate::ai::runtime::ProviderActionContext;
|
||||
|
||||
const TOOL_PREFIX: &str = "galaxy:provider-tool:v1:";
|
||||
|
||||
pub(crate) fn computer_use_tool(action: &super::AIAgentActionType) -> Option<api::message::tool_call::Tool> {
|
||||
use api::message::tool_call::{self as tool, use_computer::action as action_api};
|
||||
use computer_use::{Action, Key, MouseButton, ScrollDirection, ScrollDistance};
|
||||
let coordinates = |point: computer_use::Vector2I| Some(api::Coordinates { x: point.x(), y: point.y() });
|
||||
let button = |button: &MouseButton| match button {
|
||||
MouseButton::Left => action_api::MouseButton::Left as i32,
|
||||
MouseButton::Right => action_api::MouseButton::Right as i32,
|
||||
MouseButton::Middle => action_api::MouseButton::Middle as i32,
|
||||
MouseButton::Back => action_api::MouseButton::Back as i32,
|
||||
MouseButton::Forward => action_api::MouseButton::Forward as i32,
|
||||
};
|
||||
let key = |key: &Key| Some(action_api::Key { data: Some(match key {
|
||||
Key::Char(ch) => action_api::key::Data::Char(ch.to_string()),
|
||||
Key::Keycode(code) => action_api::key::Data::Keycode(*code),
|
||||
}) });
|
||||
match action {
|
||||
super::AIAgentActionType::RequestComputerUse(request) => Some(tool::Tool::RequestComputerUse(tool::RequestComputerUse {
|
||||
task_summary: request.task_summary.clone(),
|
||||
screenshot_params: request.screenshot_params.map(computer_screenshot),
|
||||
})),
|
||||
super::AIAgentActionType::UseComputer(request) => Some(tool::Tool::UseComputer(tool::UseComputer {
|
||||
action_summary: request.action_summary.clone(),
|
||||
post_actions_screenshot_params: request.screenshot_params.map(computer_screenshot),
|
||||
actions: request.actions.iter().map(|targeted| tool::use_computer::Action {
|
||||
target: computer_target(targeted.target),
|
||||
r#type: Some(match &targeted.action {
|
||||
Action::MouseMove { to } => action_api::Type::MouseMove(action_api::MouseMove { to: coordinates(*to) }),
|
||||
Action::MouseDown { button: b, at } => action_api::Type::MouseDown(action_api::MouseDown { button: button(b), at: coordinates(*at) }),
|
||||
Action::MouseUp { button: b } => action_api::Type::MouseUp(action_api::MouseUp { button: button(b) }),
|
||||
Action::MouseWheel { at, direction, distance } => action_api::Type::MouseWheel(action_api::MouseWheel {
|
||||
at: coordinates(*at),
|
||||
direction: match direction {
|
||||
ScrollDirection::Up => action_api::mouse_wheel::Direction::Up as i32,
|
||||
ScrollDirection::Down => action_api::mouse_wheel::Direction::Down as i32,
|
||||
ScrollDirection::Left => action_api::mouse_wheel::Direction::Left as i32,
|
||||
ScrollDirection::Right => action_api::mouse_wheel::Direction::Right as i32,
|
||||
},
|
||||
distance: Some(match distance {
|
||||
ScrollDistance::Pixels(value) => action_api::mouse_wheel::Distance::Pixels(*value),
|
||||
ScrollDistance::Clicks(value) => action_api::mouse_wheel::Distance::Clicks(*value),
|
||||
}),
|
||||
}),
|
||||
Action::TypeText { text } => action_api::Type::TypeText(action_api::TypeText { text: text.clone() }),
|
||||
Action::KeyDown { key: k } => action_api::Type::KeyDown(action_api::KeyDown { key: key(k) }),
|
||||
Action::KeyUp { key: k } => action_api::Type::KeyUp(action_api::KeyUp { key: key(k) }),
|
||||
Action::Wait(duration) => action_api::Type::Wait(action_api::Wait { duration: Some(prost_types::Duration {
|
||||
seconds: duration.as_secs() as i64, nanos: duration.subsec_nanos() as i32,
|
||||
}) }),
|
||||
}),
|
||||
}).collect(),
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn computer_target(target: computer_use::Target) -> Option<api::message::tool_call::ComputerUseTarget> {
|
||||
use api::message::tool_call::{computer_use_target, ComputerUseTarget};
|
||||
match target {
|
||||
computer_use::Target::Screen => None,
|
||||
computer_use::Target::Window { window_id, pid } => Some(ComputerUseTarget { target: Some(computer_use_target::Target::Window(computer_use_target::Window { window_id: window_id.to_string(), pid })) }),
|
||||
}
|
||||
}
|
||||
|
||||
fn computer_screenshot(params: computer_use::ScreenshotParams) -> api::message::tool_call::ScreenshotParams {
|
||||
api::message::tool_call::ScreenshotParams {
|
||||
max_long_edge_px: params.max_long_edge_px.unwrap_or_default() as i32,
|
||||
max_total_px: params.max_total_px.unwrap_or_default() as i32,
|
||||
target: computer_target(params.target),
|
||||
region: params.region.map(|region| api::message::tool_call::screenshot_params::Region {
|
||||
top_left: Some(api::Coordinates { x: region.top_left.x(), y: region.top_left.y() }),
|
||||
bottom_right: Some(api::Coordinates { x: region.bottom_right.x(), y: region.bottom_right.y() }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SavedTool {
|
||||
context: ProviderActionContext,
|
||||
|
||||
@@ -24,6 +24,29 @@ fn query(id: &str) -> api::Message {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_tool_display_and_native_restoration_preserve_the_same_actions() {
|
||||
let context = ProviderActionContext::new_for_test("task");
|
||||
let call = ToolCall {
|
||||
id: "desktop-call".into(), name: "use_computer".into(), arguments: json!({
|
||||
"action_summary": "Type and press Return", "actions": [
|
||||
{ "type": "type_text", "text": "hello" },
|
||||
{ "type": "key_down", "keycode": 36 },
|
||||
{ "type": "key_up", "keycode": 36 },
|
||||
],
|
||||
}),
|
||||
};
|
||||
let action = context.action_from_tool_call(&call).unwrap();
|
||||
let message = tool_message(&context, &call, &action).unwrap();
|
||||
let Some(api::message::Message::ToolCall(display)) = message.message else { panic!("expected tool display"); };
|
||||
let Some(api::message::tool_call::Tool::UseComputer(display)) = display.tool else { panic!("expected computer display"); };
|
||||
let display_action = crate::ai::agent::AIAgentActionType::try_from(display).unwrap();
|
||||
assert_eq!(display_action, action.action);
|
||||
let restored = super::restore_tool(&message.server_message_data).unwrap().unwrap();
|
||||
assert_eq!(restored.action, action.action);
|
||||
assert_eq!(restored.id, action.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_tools_and_results_restore_as_individual_panes_across_turns() {
|
||||
let context = ProviderActionContext::new_for_test("task");
|
||||
|
||||
@@ -83,7 +83,17 @@ impl AIAgentTodoList {
|
||||
.position(|item| item.id == completed_todo_id.clone().into())
|
||||
.map(|i| self.pending_items.remove(i))
|
||||
{
|
||||
self.completed_items.push(item);
|
||||
// Restored provider batches can redeliver a pending update and
|
||||
// its completion. Keep one completed item per stable task ID.
|
||||
if let Some(previous) = self
|
||||
.completed_items
|
||||
.iter_mut()
|
||||
.find(|old| old.id == item.id)
|
||||
{
|
||||
*previous = item;
|
||||
} else {
|
||||
self.completed_items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,16 @@ pub struct AgentManagementFilters {
|
||||
}
|
||||
|
||||
impl AgentManagementFilters {
|
||||
/// Retain only filters supported by the local session history screen.
|
||||
/// Older app state may still contain cloud-only filter selections.
|
||||
pub fn for_local_history(&self) -> Self {
|
||||
Self {
|
||||
status: self.status,
|
||||
created_on: self.created_on,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_all_but_owner(&mut self) {
|
||||
self.status = StatusFilter::default();
|
||||
self.source = SourceFilter::default();
|
||||
@@ -1204,6 +1214,98 @@ impl AgentConversationsModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns locally recoverable sessions without hosted run or server-only rows.
|
||||
pub fn get_local_entries(
|
||||
&self,
|
||||
filters: &AgentManagementFilters,
|
||||
app: &AppContext,
|
||||
) -> Vec<AgentConversationEntry> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let filters = filters.for_local_history();
|
||||
let mut entries = HashMap::new();
|
||||
|
||||
for metadata in self.conversations.values() {
|
||||
let entry = entry::entry_for_conversation(metadata, history_model, app);
|
||||
// Metadata from a live local session can precede its first disk write.
|
||||
if !entry.backing.has_ambient_run
|
||||
&& (entry.backing.has_loaded_conversation
|
||||
|| entry.backing.has_local_persisted_data
|
||||
|| !entry.backing.has_cloud_data)
|
||||
{
|
||||
entries.insert(entry.id, entry.for_local_history());
|
||||
}
|
||||
}
|
||||
|
||||
for metadata in history_model.get_local_conversations_metadata() {
|
||||
if !metadata.has_local_data && history_model.conversation(&metadata.id).is_none() {
|
||||
continue;
|
||||
}
|
||||
let id = AgentConversationEntryId::Conversation(metadata.id);
|
||||
entries.entry(id).or_insert_with(|| {
|
||||
let nav_data =
|
||||
ConversationNavigationData::from_historical_conversation_metadata(metadata);
|
||||
entry::entry_for_historical_metadata(metadata, nav_data, history_model, app)
|
||||
.for_local_history()
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
.into_values()
|
||||
.filter(|entry| entry.matches_filters(&filters, app))
|
||||
.sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_local_entry_by_id(
|
||||
&self,
|
||||
id: &AgentConversationEntryId,
|
||||
app: &AppContext,
|
||||
) -> Option<AgentConversationEntry> {
|
||||
let AgentConversationEntryId::Conversation(conversation_id) = id else {
|
||||
return None;
|
||||
};
|
||||
let entry = self.get_entry_by_id(id, app)?;
|
||||
(!entry.backing.has_ambient_run
|
||||
&& (entry.backing.has_loaded_conversation
|
||||
|| entry.backing.has_local_persisted_data
|
||||
|| (!entry.backing.has_cloud_data
|
||||
&& self.conversations.contains_key(conversation_id))))
|
||||
.then(|| entry.for_local_history())
|
||||
}
|
||||
|
||||
/// Resolve history navigation exclusively through an existing local pane or disk data.
|
||||
pub fn resolve_local_open_action(
|
||||
id: AgentConversationEntryId,
|
||||
restore_layout: Option<RestoreConversationLayout>,
|
||||
app: &AppContext,
|
||||
) -> Option<WorkspaceAction> {
|
||||
let model = Self::as_ref(app);
|
||||
let entry = model.get_local_entry_by_id(&id, app)?;
|
||||
let conversation_id = entry.identity.local_conversation_id?;
|
||||
let active_views = ActiveAgentViewsModel::as_ref(app);
|
||||
let nav_data = model
|
||||
.conversations
|
||||
.get(&conversation_id)
|
||||
.map(|metadata| &metadata.nav_data);
|
||||
|
||||
if active_views.is_conversation_open(conversation_id, app) && nav_data.is_none() {
|
||||
return active_views
|
||||
.get_terminal_view_id_for_conversation(conversation_id, app)
|
||||
.map(|terminal_view_id| WorkspaceAction::FocusTerminalViewInWorkspace {
|
||||
terminal_view_id,
|
||||
});
|
||||
}
|
||||
|
||||
Some(WorkspaceAction::RestoreOrNavigateToConversation {
|
||||
conversation_id,
|
||||
window_id: nav_data.and_then(|data| data.window_id),
|
||||
pane_view_locator: nav_data.and_then(|data| data.pane_view_locator),
|
||||
terminal_view_id: nav_data.and_then(|data| data.terminal_view_id),
|
||||
restore_layout,
|
||||
local_only: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns normalized, owned entries for agent management/navigation surfaces.
|
||||
pub fn get_entries(
|
||||
&self,
|
||||
@@ -1380,6 +1482,7 @@ impl AgentConversationsModel {
|
||||
pane_view_locator: nav_data.pane_view_locator,
|
||||
terminal_view_id: nav_data.terminal_view_id,
|
||||
restore_layout,
|
||||
local_only: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1409,6 +1512,7 @@ impl AgentConversationsModel {
|
||||
pane_view_locator: None,
|
||||
terminal_view_id: nav_data.and_then(|nav_data| nav_data.terminal_view_id),
|
||||
restore_layout,
|
||||
local_only: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,26 @@ pub struct AgentConversationCapabilities {
|
||||
}
|
||||
|
||||
impl AgentConversationEntry {
|
||||
/// Local history never offers hosted attachment, sharing, or server recovery.
|
||||
pub(super) fn for_local_history(mut self) -> Self {
|
||||
self.provenance = AgentConversationProvenance::LocalInteractive;
|
||||
self.identity.ambient_agent_task_id = None;
|
||||
self.identity.server_conversation_token = None;
|
||||
self.identity.session_id = None;
|
||||
self.display.session_status = None;
|
||||
self.display.source = None;
|
||||
self.display.environment_id = None;
|
||||
self.display.harness = None;
|
||||
self.display.request_usage = None;
|
||||
self.display.executor = None;
|
||||
self.capabilities.can_open = true;
|
||||
self.capabilities.can_copy_link = false;
|
||||
self.capabilities.can_share = false;
|
||||
self.capabilities.can_fork_locally = false;
|
||||
self.capabilities.can_cancel = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn matches_filters(
|
||||
&self,
|
||||
filters: &AgentManagementFilters,
|
||||
|
||||
@@ -16,7 +16,8 @@ use super::entry::{
|
||||
use super::{
|
||||
record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel,
|
||||
AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter,
|
||||
ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter,
|
||||
ConversationMetadata, ConversationUpdateKind, CreatedOnFilter, CreatorFilter,
|
||||
EnvironmentFilter, HarnessFilter, OwnerFilter, SourceFilter,
|
||||
RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS,
|
||||
MAX_TEAM_TASKS,
|
||||
};
|
||||
@@ -28,11 +29,12 @@ use crate::ai::agent::conversation::{
|
||||
};
|
||||
use crate::ai::ambient_agents::task::{HarnessConfig, TaskPrincipalInfo, TaskStatusMessage};
|
||||
use crate::ai::ambient_agents::{
|
||||
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
|
||||
AgentConfigSnapshot, AgentSource, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::history_model::{
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate,
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData,
|
||||
ConversationStatusUpdate,
|
||||
};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::auth::AuthStateProvider;
|
||||
@@ -814,6 +816,216 @@ fn create_server_conversation_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_history_discards_obsolete_filters_but_preserves_status_and_date() {
|
||||
let filters = AgentManagementFilters {
|
||||
owners: OwnerFilter::All,
|
||||
status: StatusFilter::Failed,
|
||||
source: SourceFilter::Specific(AgentSource::CloudMode),
|
||||
created_on: CreatedOnFilter::LastWeek,
|
||||
creator: CreatorFilter::Specific {
|
||||
name: "Former team".to_string(),
|
||||
uid: "former-user".to_string(),
|
||||
},
|
||||
artifact: ArtifactFilter::PullRequest,
|
||||
environment: EnvironmentFilter::Specific("old-environment".to_string()),
|
||||
harness: HarnessFilter::Specific(Harness::Oz),
|
||||
};
|
||||
assert_eq!(
|
||||
filters.for_local_history(),
|
||||
AgentManagementFilters {
|
||||
status: StatusFilter::Failed,
|
||||
created_on: CreatedOnFilter::LastWeek,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_history_keeps_saved_legacy_sessions_and_excludes_hosted_rows() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||
let conversation_id = AIConversationId::new();
|
||||
let task_id = "saved-root-task";
|
||||
let saved = persistence::model::AgentConversation {
|
||||
conversation: persistence::model::AgentConversationRecord {
|
||||
id: 0,
|
||||
conversation_id: conversation_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("legacy-saved-token".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
last_modified_at: Utc::now().naive_utc(),
|
||||
},
|
||||
tasks: vec![create_api_task(
|
||||
task_id,
|
||||
vec![create_message("saved-message", task_id)],
|
||||
)],
|
||||
};
|
||||
let history =
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[saved]));
|
||||
history.update(&mut app, |model, _| {
|
||||
model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata(
|
||||
"Remote-only conversation",
|
||||
"remote-only-token",
|
||||
None,
|
||||
)]);
|
||||
});
|
||||
app.add_singleton_model(|_| {
|
||||
let mut model = create_test_model();
|
||||
let task = create_test_task(&make_uuid(8400), "user-a", Utc::now());
|
||||
model.tasks.insert(task.task_id, task);
|
||||
model
|
||||
});
|
||||
app.update(|ctx| {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let entries = model.get_local_entries(&AgentManagementFilters::default(), ctx);
|
||||
assert_eq!(entries.len(), 1);
|
||||
let entry = &entries[0];
|
||||
assert_eq!(entry.identity.local_conversation_id, Some(conversation_id));
|
||||
assert!(entry.identity.server_conversation_token.is_none());
|
||||
assert!(entry.display.harness.is_none());
|
||||
assert!(!entry.capabilities.can_share);
|
||||
assert!(!entry.capabilities.can_copy_link);
|
||||
assert!(model.get_local_entry_by_id(&entry.id, ctx).is_some());
|
||||
assert!(matches!(
|
||||
AgentConversationsModel::resolve_local_open_action(entry.id, None, ctx),
|
||||
Some(WorkspaceAction::RestoreOrNavigateToConversation {
|
||||
conversation_id: restored_id,
|
||||
local_only: true,
|
||||
..
|
||||
}) if restored_id == conversation_id
|
||||
));
|
||||
let remote_id = history
|
||||
.as_ref(ctx)
|
||||
.find_conversation_id_by_server_token(&ServerConversationToken::new(
|
||||
"remote-only-token".to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(AgentConversationsModel::resolve_local_open_action(
|
||||
AgentConversationEntryId::Conversation(remote_id), None, ctx,
|
||||
).is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_history_loader_does_not_fetch_missing_cloud_data() {
|
||||
App::test((), |mut app| async move {
|
||||
add_entry_projection_test_models(&mut app);
|
||||
let history = BlocklistAIHistoryModel::handle(&app);
|
||||
history.update(&mut app, |model, _| {
|
||||
model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata(
|
||||
"Remote-only conversation",
|
||||
"remote-only-token",
|
||||
None,
|
||||
)]);
|
||||
});
|
||||
let future = app.update(|ctx| {
|
||||
let model = history.as_ref(ctx);
|
||||
let id = model
|
||||
.find_conversation_id_by_server_token(&ServerConversationToken::new(
|
||||
"remote-only-token".to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
model.load_local_conversation_data(id)
|
||||
});
|
||||
// No ServerApiProvider is installed: any accidental server fallback would fail.
|
||||
assert!(future.await.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_history_recovers_current_live_state_without_hosted_task_shadowing() {
|
||||
App::test((), |mut app| async move {
|
||||
add_entry_projection_test_models(&mut app);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let run_id = make_uuid(8401);
|
||||
let conversation = create_restored_conversation(
|
||||
conversation_id,
|
||||
"live-root-task",
|
||||
AgentConversationData {
|
||||
run_id: Some(run_id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let history = BlocklistAIHistoryModel::handle(&app);
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
|
||||
model
|
||||
.conversation_mut(&conversation_id)
|
||||
.unwrap()
|
||||
.set_last_event_sequence(42);
|
||||
});
|
||||
let mut model = create_test_model();
|
||||
let task = create_test_task(&run_id, "user-a", Utc::now());
|
||||
model.tasks.insert(task.task_id, task);
|
||||
let future = app.update(|ctx| {
|
||||
let entries = model.get_local_entries(&AgentManagementFilters::default(), ctx);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].id,
|
||||
AgentConversationEntryId::Conversation(conversation_id)
|
||||
);
|
||||
assert!(entries[0].identity.ambient_agent_task_id.is_none());
|
||||
assert!(model.get_local_entry_by_id(&entries[0].id, ctx).is_some());
|
||||
history
|
||||
.as_ref(ctx)
|
||||
.load_local_conversation_data(conversation_id)
|
||||
});
|
||||
let Some(CloudConversationData::Oz(restored)) = future.await else {
|
||||
panic!("live local session should restore without disk or server access");
|
||||
};
|
||||
assert_eq!(restored.id(), conversation_id);
|
||||
assert_eq!(restored.last_event_sequence(), Some(42));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_history_excludes_cached_ambient_sessions_from_list_and_lookup() {
|
||||
App::test((), |mut app| async move {
|
||||
add_entry_projection_test_models(&mut app);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let task_id = make_uuid(8402).parse().unwrap();
|
||||
let mut conversation = create_restored_conversation(
|
||||
conversation_id,
|
||||
"ambient-root-task",
|
||||
AgentConversationData::default(),
|
||||
);
|
||||
conversation.set_server_metadata(create_server_conversation_metadata(
|
||||
"Cached hosted run",
|
||||
"cached-ambient-token",
|
||||
Some(task_id),
|
||||
));
|
||||
let history = BlocklistAIHistoryModel::handle(&app);
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
|
||||
});
|
||||
let mut model = create_test_model();
|
||||
model.conversations.insert(
|
||||
conversation_id,
|
||||
create_test_conversation_metadata(conversation_id, "Cached hosted run"),
|
||||
);
|
||||
let future = app.update(|ctx| {
|
||||
assert!(model
|
||||
.get_local_entries(&AgentManagementFilters::default(), ctx)
|
||||
.is_empty());
|
||||
assert!(model
|
||||
.get_local_entry_by_id(
|
||||
&AgentConversationEntryId::Conversation(conversation_id),
|
||||
ctx,
|
||||
)
|
||||
.is_none());
|
||||
history
|
||||
.as_ref(ctx)
|
||||
.load_local_conversation_data(conversation_id)
|
||||
});
|
||||
assert!(future.await.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_entries_includes_task_only_entry() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -36,7 +36,7 @@ use siphasher::sip::SipHasher;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject,
|
||||
AgentConversationEntry, AgentConversationEntryId,
|
||||
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
|
||||
ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
|
||||
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
|
||||
@@ -55,7 +55,6 @@ use crate::ai::agent_management::telemetry::{
|
||||
};
|
||||
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
|
||||
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
|
||||
use crate::ai::blocklist::format_credits;
|
||||
use crate::ai::conversation_details_panel::{
|
||||
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
|
||||
};
|
||||
@@ -89,7 +88,6 @@ use crate::workflows::WorkflowType;
|
||||
use crate::workspace::{
|
||||
ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction,
|
||||
};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
|
||||
|
||||
lazy_static! {
|
||||
@@ -311,7 +309,7 @@ impl AgentManagementView {
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.set_placeholder_text("Search", ctx);
|
||||
editor.set_placeholder_text("Search local sessions", ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&search_editor, |me, _handle, event, ctx| {
|
||||
@@ -319,7 +317,7 @@ impl AgentManagementView {
|
||||
});
|
||||
|
||||
let new_agent_button = CompactibleActionButton::new(
|
||||
"New agent".to_string(),
|
||||
"New session".to_string(),
|
||||
None,
|
||||
ButtonSize::Small,
|
||||
AgentManagementViewAction::ShowAgentTypeSelector,
|
||||
@@ -333,7 +331,9 @@ impl AgentManagementView {
|
||||
|
||||
let has_dismissed_setup_guide = *AISettings::as_ref(ctx).did_dismiss_cloud_setup_guide;
|
||||
|
||||
let filters = persisted_filters.map(|p| p.filters).unwrap_or_default();
|
||||
let filters = persisted_filters
|
||||
.map(|persisted| persisted.filters.for_local_history())
|
||||
.unwrap_or_default();
|
||||
|
||||
let details_panel: ViewHandle<ConversationDetailsPanel> =
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
@@ -378,28 +378,17 @@ impl AgentManagementView {
|
||||
view.update_creator_dropdown(ctx);
|
||||
view.update_environment_dropdown(ctx);
|
||||
|
||||
// Trigger server fetch if persisted filters differ from defaults
|
||||
// (team tasks are not loaded at startup, so we need to fetch them)
|
||||
if view.filters != AgentManagementFilters::default() {
|
||||
view.trigger_filter_fetch(ctx);
|
||||
}
|
||||
|
||||
view.get_tasks_from_model(ctx);
|
||||
view
|
||||
}
|
||||
|
||||
fn get_view_state(&self, app: &AppContext) -> ViewState {
|
||||
let model = AgentConversationsModel::as_ref(app);
|
||||
let has_items = model.has_items();
|
||||
let has_items = !model
|
||||
.get_local_entries(&AgentManagementFilters::default(), app)
|
||||
.is_empty();
|
||||
|
||||
// If loading with zero items, show skeleton cards
|
||||
// If loading with items, show list of interactive conversations (with loading indicator in header)
|
||||
if model.is_loading() && !has_items {
|
||||
return ViewState::Loading;
|
||||
}
|
||||
|
||||
// Show setup guide if: no items (zero state) or user clicked button to toggle on the guide
|
||||
if !has_items || self.is_viewing_setup_guide {
|
||||
if !has_items {
|
||||
return ViewState::SetupGuide { has_items };
|
||||
}
|
||||
|
||||
@@ -866,7 +855,7 @@ impl AgentManagementView {
|
||||
|
||||
/// Common handler for filter changes: fetch, refresh tasks, and save.
|
||||
fn on_filter_changed(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.trigger_filter_fetch(ctx);
|
||||
self.filters = self.filters.for_local_history();
|
||||
self.get_tasks_from_model(ctx);
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
}
|
||||
@@ -888,11 +877,9 @@ impl AgentManagementView {
|
||||
|
||||
/// Shows the setup guide from a deep-link/action without toggling it off on repeated calls.
|
||||
pub(crate) fn show_setup_guide_from_link(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_viewing_setup_guide {
|
||||
send_telemetry_from_ctx!(AgentManagementTelemetryEvent::OpenSetupGuide, ctx);
|
||||
}
|
||||
self.is_viewing_setup_guide = true;
|
||||
ctx.notify();
|
||||
// Older links may target cloud onboarding. Keep them within local history.
|
||||
self.is_viewing_setup_guide = false;
|
||||
self.get_tasks_from_model(ctx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -905,10 +892,10 @@ impl AgentManagementView {
|
||||
environment_id: String,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// This navigation should show the team/global task runs list.
|
||||
self.filters.owners = OwnerFilter::All;
|
||||
// A legacy environment link must not invisibly filter local sessions.
|
||||
let _ = environment_id;
|
||||
self.filters.owners = OwnerFilter::PersonalOnly;
|
||||
self.filters.reset_all_but_owner();
|
||||
self.filters.environment = EnvironmentFilter::Specific(environment_id);
|
||||
self.update_filter_buttons(ctx);
|
||||
|
||||
// Clear search query.
|
||||
@@ -952,7 +939,7 @@ impl AgentManagementView {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let search_query = self.search_query.trim().to_lowercase();
|
||||
let cards: Vec<CardData> = model
|
||||
.get_entries(&self.filters, ctx)
|
||||
.get_local_entries(&self.filters, ctx)
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if search_query.is_empty() {
|
||||
@@ -962,16 +949,7 @@ impl AgentManagementView {
|
||||
})
|
||||
.map(|entry| {
|
||||
let item_id = entry.id;
|
||||
let copy_link_url = entry
|
||||
.capabilities
|
||||
.can_copy_link
|
||||
.then(|| {
|
||||
AgentConversationsModel::resolve_copy_link(
|
||||
AgentConversationNavigationSubject::Entry(entry.id),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
let copy_link_url = None;
|
||||
let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url);
|
||||
if FeatureFlag::AgentManagementDetailsView.is_enabled() {
|
||||
config.view_details_item_id = Some(item_id);
|
||||
@@ -1100,6 +1078,9 @@ impl AgentManagementView {
|
||||
{
|
||||
config.delete_conversation_id = None;
|
||||
}
|
||||
if !entry.capabilities.can_fork_locally {
|
||||
config.fork_conversation_id = None;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
@@ -1184,7 +1165,7 @@ impl AgentManagementView {
|
||||
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let conversation_title = model
|
||||
.get_entry_by_id(item_id, ctx)
|
||||
.get_local_entry_by_id(item_id, ctx)
|
||||
.map(|entry| entry.display.title)
|
||||
.unwrap_or_else(|| "Conversation".to_string());
|
||||
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
|
||||
@@ -1341,18 +1322,15 @@ impl AgentManagementView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let Some(entry) = model.get_entry_by_id(item_id, ctx) else {
|
||||
let Some(entry) = model.get_local_entry_by_id(item_id, ctx) else {
|
||||
return;
|
||||
};
|
||||
let open_action = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
let open_action = AgentConversationsModel::resolve_local_open_action(
|
||||
*item_id,
|
||||
Some(RestoreConversationLayout::NewTab),
|
||||
ctx,
|
||||
);
|
||||
let copy_link_url = AgentConversationsModel::resolve_copy_link(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
ctx,
|
||||
);
|
||||
let copy_link_url = None;
|
||||
let task = entry
|
||||
.identity
|
||||
.ambient_agent_task_id
|
||||
@@ -1362,7 +1340,8 @@ impl AgentManagementView {
|
||||
task.as_ref(),
|
||||
open_action,
|
||||
copy_link_url,
|
||||
);
|
||||
)
|
||||
.for_local_history();
|
||||
|
||||
self.details_panel.update(ctx, |p, ctx| {
|
||||
p.set_conversation_details(data, ctx);
|
||||
@@ -1377,7 +1356,7 @@ impl AgentManagementView {
|
||||
) {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| {
|
||||
let entry = model.get_entry_by_id(&card.item_id, ctx)?;
|
||||
let entry = model.get_local_entry_by_id(&card.item_id, ctx)?;
|
||||
(entry.identity.local_conversation_id == Some(conversation_id))
|
||||
.then_some((index, entry))
|
||||
}) else {
|
||||
@@ -1638,7 +1617,7 @@ impl AgentManagementView {
|
||||
};
|
||||
|
||||
let model = AgentConversationsModel::as_ref(app);
|
||||
let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else {
|
||||
let Some(entry) = model.get_local_entry_by_id(&card_state.item_id, app) else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
@@ -1673,7 +1652,7 @@ impl AgentManagementView {
|
||||
let mut card_content = Flex::column()
|
||||
.with_spacing(CARD_ROW_SPACING)
|
||||
.with_child(Self::render_header_row(card_state, entry, appearance))
|
||||
.with_child(Self::render_metadata_row(entry, appearance, app));
|
||||
.with_child(Self::render_metadata_row(entry, appearance));
|
||||
|
||||
// Add artifacts row if there is a buttons view
|
||||
if let Some(buttons_element) = artifact_buttons_element {
|
||||
@@ -1816,25 +1795,14 @@ impl AgentManagementView {
|
||||
fn render_metadata_row(
|
||||
entry: &AgentConversationEntry,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
let mut metadata_parts = Vec::new();
|
||||
|
||||
if let Some(source) = &entry.display.source {
|
||||
metadata_parts.push(format!("Source: {}", source.display_name()));
|
||||
}
|
||||
|
||||
let availability = HarnessAvailabilityModel::as_ref(app);
|
||||
if availability.should_show_harness_selector() {
|
||||
if let Some(harness) = entry.display.harness {
|
||||
metadata_parts.push(format!(
|
||||
"Harness: {}",
|
||||
availability.display_name_for(harness)
|
||||
));
|
||||
}
|
||||
if let Some(directory) = &entry.display.working_directory {
|
||||
metadata_parts.push(directory.clone());
|
||||
}
|
||||
|
||||
if let Some(executor) = &entry.display.executor {
|
||||
@@ -1859,8 +1827,8 @@ impl AgentManagementView {
|
||||
metadata_parts.push(format!("Run time: {run_time}"));
|
||||
}
|
||||
|
||||
if let Some(usage) = entry.display.request_usage.map(format_credits) {
|
||||
metadata_parts.push(format!("Credits used: {usage}"));
|
||||
if metadata_parts.is_empty() {
|
||||
metadata_parts.push("Saved on this device".to_string());
|
||||
}
|
||||
|
||||
Text::new(metadata_parts.join(" • "), font_family, font_size)
|
||||
@@ -1870,13 +1838,7 @@ impl AgentManagementView {
|
||||
|
||||
// Render the main page header based on the current view state
|
||||
fn render_header(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
match self.get_view_state(app) {
|
||||
ViewState::Loading => self.render_loading_header(app),
|
||||
ViewState::SetupGuide {
|
||||
has_items: has_tasks,
|
||||
} => self.render_setup_guide_header(has_tasks, app),
|
||||
ViewState::NoFilterMatches | ViewState::HasTasks => self.render_task_list_header(app),
|
||||
}
|
||||
self.render_task_list_header(app)
|
||||
}
|
||||
|
||||
fn render_setup_guide_header(&self, has_tasks: bool, app: &AppContext) -> Box<dyn Element> {
|
||||
@@ -1920,17 +1882,11 @@ impl AgentManagementView {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let is_loading = AgentConversationsModel::handle(app)
|
||||
.as_ref(app)
|
||||
.is_loading();
|
||||
|
||||
let is_on_team = UserWorkspaces::as_ref(app).current_team().is_some();
|
||||
|
||||
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
|
||||
|
||||
let build_header = |use_expanded: bool| {
|
||||
let title = Text::new_inline(
|
||||
"Runs",
|
||||
"AI sessions",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() + 4.,
|
||||
)
|
||||
@@ -1938,12 +1894,6 @@ impl AgentManagementView {
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let setup_guide_button = if use_expanded {
|
||||
self.setup_guide_button.expanded_button()
|
||||
} else {
|
||||
self.setup_guide_button.compact_button()
|
||||
};
|
||||
|
||||
let new_agent_button = if use_expanded {
|
||||
self.new_agent_button.expanded_button()
|
||||
} else {
|
||||
@@ -1956,17 +1906,7 @@ impl AgentManagementView {
|
||||
.with_spacing(4.)
|
||||
.with_child(Container::new(title).with_margin_right(12.).finish());
|
||||
|
||||
if is_on_team {
|
||||
header_top.add_child(ChildView::new(&self.personal_filter_button).finish());
|
||||
header_top.add_child(ChildView::new(&self.all_filter_button).finish());
|
||||
}
|
||||
|
||||
if is_loading {
|
||||
header_top.add_child(self.render_cloud_loading_icon(appearance));
|
||||
}
|
||||
|
||||
header_top.add_child(Expanded::new(1., Empty::new().finish()).finish());
|
||||
header_top.add_child(ChildView::new(setup_guide_button).finish());
|
||||
|
||||
if !cfg!(target_family = "wasm") {
|
||||
header_top.add_child(ChildView::new(new_agent_button).finish());
|
||||
@@ -1976,19 +1916,7 @@ impl AgentManagementView {
|
||||
.with_spacing(4.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(ChildView::new(&self.status_dropdown).finish())
|
||||
.with_child(ChildView::new(&self.source_dropdown).finish())
|
||||
.with_child(ChildView::new(&self.created_on_dropdown).finish())
|
||||
.with_child(ChildView::new(&self.artifact_dropdown).finish());
|
||||
|
||||
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
|
||||
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
|
||||
}
|
||||
|
||||
filters_wrap.add_child(ChildView::new(&self.environment_dropdown).finish());
|
||||
|
||||
if self.filters.owners != OwnerFilter::PersonalOnly {
|
||||
filters_wrap.add_child(ChildView::new(&self.creator_dropdown).finish());
|
||||
}
|
||||
.with_child(ChildView::new(&self.created_on_dropdown).finish());
|
||||
|
||||
if self.filters.is_filtering() {
|
||||
filters_wrap.add_child(ChildView::new(&self.clear_all_filters_button).finish());
|
||||
@@ -2198,7 +2126,19 @@ impl View for AgentManagementView {
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let main_content = match self.get_view_state(app) {
|
||||
ViewState::Loading => self.render_loading_state(app),
|
||||
ViewState::SetupGuide { .. } => ChildView::new(&self.cloud_setup_guide_view).finish(),
|
||||
ViewState::SetupGuide { .. } => {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Align::new(
|
||||
Text::new(
|
||||
"No saved AI sessions on this device. Start a session to keep its history here.",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ViewState::NoFilterMatches => self.render_no_results_view(app),
|
||||
ViewState::HasTasks => self.render_default_scroll_view(app),
|
||||
};
|
||||
@@ -2388,16 +2328,17 @@ impl TypedActionView for AgentManagementView {
|
||||
}
|
||||
AgentManagementViewAction::ShowAgentTypeSelector => {
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::AgentTypeSelectorOpened,
|
||||
AgentManagementTelemetryEvent::SpawnNewLocalAgent,
|
||||
ctx
|
||||
);
|
||||
self.is_agent_type_selector_open = true;
|
||||
ctx.focus(&self.agent_type_selector);
|
||||
ctx.notify();
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::NewTabInAgentMode {
|
||||
entrypoint: AgentModeEntrypoint::AgentManagementView,
|
||||
zero_state_prompt_suggestion_type: None,
|
||||
});
|
||||
}
|
||||
AgentManagementViewAction::OpenSession { item_id } => {
|
||||
let Some(action) = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
let Some(action) = AgentConversationsModel::resolve_local_open_action(
|
||||
*item_id,
|
||||
Some(RestoreConversationLayout::NewTab),
|
||||
ctx,
|
||||
) else {
|
||||
|
||||
@@ -41,7 +41,7 @@ const PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME: &str = "pending-hook-output.ack";
|
||||
const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS";
|
||||
const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000;
|
||||
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str =
|
||||
"Oz mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
|
||||
"Galaxy mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
|
||||
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
|
||||
"\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later.";
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@ use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
pub(crate) enum BootstrapError {
|
||||
/// The PTY or shell process failed before the bootstrap script completed.
|
||||
/// When `reason` is `Some`, the message is
|
||||
/// "Shell spawn failed: {reason}. Check the Warp logs for details."
|
||||
/// "Shell spawn failed: {reason}. Check the Galaxy logs for details."
|
||||
/// When `reason` is `None`, it is
|
||||
/// "Shell spawn failed. Check the Warp logs for details."
|
||||
/// "Shell spawn failed. Check the Galaxy logs for details."
|
||||
PtySpawnFailed { reason: Option<String> },
|
||||
/// The bootstrap script did not complete within the expected time.
|
||||
TimedOut,
|
||||
@@ -58,16 +58,16 @@ impl std::fmt::Display for BootstrapError {
|
||||
BootstrapError::PtySpawnFailed { reason: Some(r) } => {
|
||||
write!(
|
||||
f,
|
||||
"Shell spawn failed: {r}. Check the Warp logs for details."
|
||||
"Shell spawn failed: {r}. Check the Galaxy logs for details."
|
||||
)
|
||||
}
|
||||
BootstrapError::PtySpawnFailed { reason: None } => {
|
||||
write!(f, "Shell spawn failed. Check the Warp logs for details.")
|
||||
write!(f, "Shell spawn failed. Check the Galaxy logs for details.")
|
||||
}
|
||||
BootstrapError::TimedOut => write!(
|
||||
f,
|
||||
"Terminal session did not start within the expected time. \
|
||||
Check the Warp logs for details."
|
||||
Check the Galaxy logs for details."
|
||||
),
|
||||
BootstrapError::InternalError => {
|
||||
write!(f, "An unexpected internal error occurred during bootstrap.")
|
||||
|
||||
@@ -112,7 +112,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
|
||||
kind: AgentTipKind::SlashCommands,
|
||||
},
|
||||
AgentTip {
|
||||
description: "<keybinding> to open the Command Palette and access Warp actions and shortcuts.".to_string(),
|
||||
description: "<keybinding> to open the Command Palette and access Galaxy actions and shortcuts.".to_string(),
|
||||
link: Some("https://docs.warp.dev/terminal/command-palette".to_string()),
|
||||
binding_name: Some(TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME),
|
||||
action: Some(WorkspaceAction::OpenPalette {
|
||||
|
||||
@@ -14,6 +14,10 @@ use tokio::sync::Mutex;
|
||||
use vec1::vec1;
|
||||
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent};
|
||||
use crate::ai::provider::authentication::{
|
||||
BedrockAuthConnection, connection_revision, credentials_are_current,
|
||||
};
|
||||
use crate::ai::provider::client::BedrockClientConfig;
|
||||
use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
@@ -56,7 +60,7 @@ fn aws_profile_reference_for_message(profile: &str, capitalize_first_word: bool)
|
||||
}
|
||||
}
|
||||
|
||||
fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String {
|
||||
pub(crate) fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String {
|
||||
match err {
|
||||
CredentialsError::CredentialsNotLoaded(_) => format!(
|
||||
"AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.",
|
||||
@@ -129,15 +133,17 @@ fn aws_credentials_state_for_error(err: LoadAwsCredentialsError) -> AwsCredentia
|
||||
/// Loads AWS credentials from the AWS SDK.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `profile` - AWS profile name. If empty, uses the default AWS SDK behavior
|
||||
/// * `connection` - Effective AWS connection. If its profile is empty, uses the default AWS SDK behavior
|
||||
/// (checks AWS_PROFILE env var, then uses "default").
|
||||
pub async fn load_aws_credentials_from_sdk(
|
||||
profile: &str,
|
||||
connection: &BedrockClientConfig,
|
||||
) -> Result<AwsCredentials, LoadAwsCredentialsError> {
|
||||
let region_provider = aws_config::meta::region::RegionProviderChain::default_provider();
|
||||
let loader =
|
||||
aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider);
|
||||
let loader = if profile.trim().is_empty() || profile.trim() == "default" {
|
||||
let profile = &connection.profile;
|
||||
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
|
||||
if !connection.region.is_empty() {
|
||||
loader = loader.region(aws_config::Region::new(connection.region.clone()));
|
||||
}
|
||||
let loader = if profile.trim().is_empty() {
|
||||
loader // Let AWS SDK use its default behavior
|
||||
} else {
|
||||
loader.profile_name(profile)
|
||||
@@ -150,14 +156,15 @@ pub async fn load_aws_credentials_from_sdk(
|
||||
|
||||
let creds = provider.provide_credentials().await.map_err(|e| {
|
||||
let message = user_facing_aws_credentials_error_message(&e, profile);
|
||||
log::warn!("{e}");
|
||||
// TODO(isaiah): turn this full SDK dump back down to debug once we've resolved
|
||||
// the current customer-facing AWS credential issue and no longer need prod-visible
|
||||
// provider internals for support debugging.
|
||||
log::warn!("{e:#?}");
|
||||
log::warn!("AWS credential reload failed: {message}");
|
||||
log::info!("AWS credential load failure message shown to user: {message}");
|
||||
LoadAwsCredentialsError::CredentialsLoadFailed(message)
|
||||
})?;
|
||||
if !credentials_are_current(creds.expiry(), SystemTime::now()) {
|
||||
return Err(LoadAwsCredentialsError::CredentialsLoadFailed(
|
||||
"AWS credentials have expired. Refresh your AWS login.".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AwsCredentials::new(
|
||||
creds.access_key_id().to_string(),
|
||||
@@ -198,8 +205,10 @@ impl AwsCredentialRefresher for ApiKeyManager {
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
let auth_command = &AISettings::as_ref(ctx).bedrock_auth_refresh_command;
|
||||
if command.trim().starts_with(auth_command.trim()) {
|
||||
let auth_command = BedrockAuthConnection::current(ctx).command;
|
||||
if !auth_command.trim().is_empty()
|
||||
&& command.trim().starts_with(auth_command.trim())
|
||||
{
|
||||
log::debug!("Detected AWS auth command completion, refreshing credentials");
|
||||
drop(refresh_aws_credentials(manager, ctx));
|
||||
}
|
||||
@@ -225,6 +234,8 @@ impl AwsCredentialRefresher for ApiKeyManager {
|
||||
if matches!(
|
||||
event,
|
||||
AISettingsChangedEvent::BedrockProfile { .. }
|
||||
| AISettingsChangedEvent::BedrockAuthMethod { .. }
|
||||
| AISettingsChangedEvent::BedrockRegion { .. }
|
||||
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
|
||||
| AISettingsChangedEvent::BedrockEnabled { .. }
|
||||
) {
|
||||
@@ -266,15 +277,28 @@ fn refresh_aws_credentials_local_chain(
|
||||
return Box::pin(async { Ok(()) });
|
||||
}
|
||||
|
||||
let profile = (*AISettings::as_ref(ctx).bedrock_profile).clone();
|
||||
let connection = BedrockAuthConnection::current(ctx);
|
||||
if connection.config.auth_method == crate::settings::BedrockAuthMethod::StaticKeys {
|
||||
manager.set_aws_credentials_state(AwsCredentialsState::Missing, ctx);
|
||||
return Box::pin(async { Ok(()) });
|
||||
}
|
||||
let config = connection.config.clone();
|
||||
let revision = connection_revision(ctx);
|
||||
|
||||
manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx);
|
||||
|
||||
let (tx, rx) = channel();
|
||||
// credential fetch from aws cli's disk cache
|
||||
let _ = ctx.spawn(
|
||||
async move { load_aws_credentials_from_sdk(&profile).await },
|
||||
async move { load_aws_credentials_from_sdk(&config).await },
|
||||
move |manager, result, ctx| {
|
||||
if connection_revision(ctx) != revision
|
||||
|| BedrockAuthConnection::current(ctx) != connection
|
||||
|| manager.aws_credentials_refresh_strategy() != AwsCredentialsRefreshStrategy::LocalChain
|
||||
{
|
||||
let _ = tx.send(Err("AWS connection changed during credential reload".to_owned()));
|
||||
return;
|
||||
}
|
||||
let (new_state, tx_result) = match result {
|
||||
Ok(credentials) => (
|
||||
AwsCredentialsState::Loaded {
|
||||
|
||||
@@ -418,19 +418,72 @@ fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bo
|
||||
} else {
|
||||
ToolResultStatus::Success
|
||||
};
|
||||
let content = action_result.result.model_content();
|
||||
let (content, images) = computer_use_model_content(&action_result.result)
|
||||
.unwrap_or_else(|| (action_result.result.model_content(), Vec::new()));
|
||||
let content = if permission_denied {
|
||||
format!("Permission denied by the user. {content}")
|
||||
} else {
|
||||
content
|
||||
};
|
||||
ToolResult {
|
||||
images: if permission_denied { Vec::new() } else { images },
|
||||
call_id: action_result.id.to_string(),
|
||||
content,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn computer_use_model_content(
|
||||
result: &AIAgentActionResultType,
|
||||
) -> Option<(String, Vec<galaxy_agent_core::ToolResultImage>)> {
|
||||
let (screenshot, mut metadata) = match result {
|
||||
AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Approved {
|
||||
screenshot,
|
||||
platform,
|
||||
windows,
|
||||
}) => (
|
||||
Some(screenshot),
|
||||
serde_json::json!({
|
||||
"status": "approved",
|
||||
"platform": format!("{platform:?}"),
|
||||
"windows": windows,
|
||||
}),
|
||||
),
|
||||
AIAgentActionResultType::UseComputer(UseComputerResult::Success(result)) => (
|
||||
result.screenshot.as_ref(),
|
||||
serde_json::json!({
|
||||
"status": "success",
|
||||
"cursor_position": result.cursor_position.map(|point| serde_json::json!({
|
||||
"x": point.x(), "y": point.y(),
|
||||
})),
|
||||
"windows": result.windows,
|
||||
"captured_window": result.captured_window.map(|window| serde_json::json!({
|
||||
"window_id": window.window_id,
|
||||
"width_px": window.width_px,
|
||||
"height_px": window.height_px,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let images = screenshot
|
||||
.map(|screenshot| {
|
||||
metadata["screenshot"] = serde_json::json!({
|
||||
"width": screenshot.width,
|
||||
"height": screenshot.height,
|
||||
"original_width": screenshot.original_width,
|
||||
"original_height": screenshot.original_height,
|
||||
"coordinate_space": "Use original physical pixels; scale image coordinates by original_width/width and original_height/height.",
|
||||
});
|
||||
vec![galaxy_agent_core::ToolResultImage {
|
||||
data: screenshot.data.clone(),
|
||||
mime_type: screenshot.mime_type.to_string(),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some((metadata.to_string(), images))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn action_tool_name(action: &AIAgentAction) -> String {
|
||||
action
|
||||
|
||||
@@ -24,10 +24,8 @@ impl UseComputerExecutor {
|
||||
return false;
|
||||
};
|
||||
|
||||
// We unconditionally return true here because this action is only executed by
|
||||
// the computer use subagent, which cannot begin without the user approving it via
|
||||
// a `RequestComputerUse` action, and the approval extends to all computer use
|
||||
// actions within that computer use subagent.
|
||||
// The shared action boundary applies the active permission mode to every action.
|
||||
// Once that check passes, the executor has no additional approval requirement.
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,26 @@ fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
|
||||
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_results_keep_real_image_bytes_and_coordinate_metadata() {
|
||||
let screenshot = computer_use::Screenshot {
|
||||
width: 500, height: 300, original_width: 1000, original_height: 600,
|
||||
data: vec![1, 2, 3, 4], mime_type: "image/png".into(),
|
||||
};
|
||||
let action = action_result("desktop", AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Approved {
|
||||
screenshot, platform: computer_use::Platform::Mac, windows: Vec::new(),
|
||||
}));
|
||||
let result = domain_tool_result(&action, false);
|
||||
assert_eq!(result.images.len(), 1);
|
||||
assert_eq!(result.images[0].data, vec![1, 2, 3, 4]);
|
||||
assert_eq!(result.images[0].mime_type, "image/png");
|
||||
let metadata: serde_json::Value = serde_json::from_str(&result.content).unwrap();
|
||||
assert_eq!(metadata["platform"], "Mac");
|
||||
assert_eq!(metadata["screenshot"]["width"], 500);
|
||||
assert_eq!(metadata["screenshot"]["original_width"], 1000);
|
||||
assert!(domain_tool_result(&action, true).images.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_permission_kinds_match_the_safety_boundary() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -443,7 +443,7 @@ impl AgentInputFooter {
|
||||
ActionButton::new("Enable notifications", InstallPluginButtonTheme)
|
||||
.with_icon(Icon::Download)
|
||||
.with_tooltip(
|
||||
"Install the Warp plugin to enable rich agent notifications within Warp",
|
||||
"Install the Galaxy plugin to enable rich agent notifications within Galaxy",
|
||||
)
|
||||
.with_size(cli_button_size)
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
@@ -456,7 +456,7 @@ impl AgentInputFooter {
|
||||
let plugin_instructions_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Notifications setup instructions", InstallPluginButtonTheme)
|
||||
.with_icon(Icon::Info)
|
||||
.with_tooltip("View instructions to install the Warp plugin")
|
||||
.with_tooltip("View instructions to install the Galaxy plugin")
|
||||
.with_size(cli_button_size)
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.with_adjoined_side(AdjoinedSide::Right)
|
||||
@@ -468,9 +468,9 @@ impl AgentInputFooter {
|
||||
});
|
||||
|
||||
let update_plugin_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Update Warp plugin", InstallPluginButtonTheme)
|
||||
ActionButton::new("Update Galaxy plugin", InstallPluginButtonTheme)
|
||||
.with_icon(Icon::Download)
|
||||
.with_tooltip("A new version of the Warp plugin is available")
|
||||
.with_tooltip("A new version of the Galaxy plugin is available")
|
||||
.with_size(cli_button_size)
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.with_adjoined_side(AdjoinedSide::Right)
|
||||
@@ -482,7 +482,7 @@ impl AgentInputFooter {
|
||||
let update_instructions_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Plugin update instructions", InstallPluginButtonTheme)
|
||||
.with_icon(Icon::Info)
|
||||
.with_tooltip("View instructions to update the Warp plugin")
|
||||
.with_tooltip("View instructions to update the Galaxy plugin")
|
||||
.with_size(cli_button_size)
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.with_adjoined_side(AdjoinedSide::Right)
|
||||
@@ -1396,10 +1396,10 @@ impl AgentInputFooter {
|
||||
.cli_agent(ctx)
|
||||
.and_then(plugin_manager_for)
|
||||
.map(|m| m.install_success_message())
|
||||
.unwrap_or("Warp plugin installed. Please restart the session to activate.");
|
||||
.unwrap_or("Galaxy plugin installed. Please restart the session to activate.");
|
||||
self.handle_plugin_operation(
|
||||
"Installing Warp plugin...",
|
||||
"Failed to install Warp plugin",
|
||||
"Installing Galaxy plugin...",
|
||||
"Failed to install Galaxy plugin",
|
||||
success_msg,
|
||||
PluginChipTelemetryKind::Install,
|
||||
|manager| async move { manager.install().await },
|
||||
@@ -1413,10 +1413,10 @@ impl AgentInputFooter {
|
||||
.cli_agent(ctx)
|
||||
.and_then(plugin_manager_for)
|
||||
.map(|m| m.update_success_message())
|
||||
.unwrap_or("Warp plugin updated. Please restart the session to activate.");
|
||||
.unwrap_or("Galaxy plugin updated. Please restart the session to activate.");
|
||||
self.handle_plugin_operation(
|
||||
"Updating Warp plugin...",
|
||||
"Failed to update Warp plugin",
|
||||
"Updating Galaxy plugin...",
|
||||
"Failed to update Galaxy plugin",
|
||||
success_msg,
|
||||
PluginChipTelemetryKind::Update,
|
||||
|manager| async move { manager.update().await },
|
||||
@@ -2823,7 +2823,7 @@ impl ActionButtonTheme for ActiveMicButtonTheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// Green-accented theme for the "Install Warp plugin" chip.
|
||||
/// Green-accented theme for the "Install Galaxy plugin" chip.
|
||||
struct InstallPluginButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for InstallPluginButtonTheme {
|
||||
@@ -2863,7 +2863,7 @@ async fn write_install_log(agent: CLIAgent, err: &PluginInstallError) -> Option<
|
||||
let log_path = env::temp_dir().join("warp-plugin-install.log");
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
let contents = format!(
|
||||
"Warp plugin installation — {agent:?}\n\
|
||||
"Galaxy plugin installation — {agent:?}\n\
|
||||
{now}\n\
|
||||
\n\
|
||||
{log}",
|
||||
|
||||
@@ -129,7 +129,7 @@ impl AgentToolbarItemKind {
|
||||
Self::NLDToggle => "Autodetection",
|
||||
Self::VoiceInput => "Voice Input",
|
||||
Self::FileAttach => "Attach File",
|
||||
Self::ContextWindowUsage => "Warp Context Indicator",
|
||||
Self::ContextWindowUsage => "Galaxy Context Indicator",
|
||||
Self::LLMContextUsage => "LLM Context Usage",
|
||||
Self::LLMCacheDetails => "LLM Cache Details",
|
||||
Self::FileExplorer => "File Explorer",
|
||||
|
||||
@@ -3998,12 +3998,7 @@ impl AIBlock {
|
||||
});
|
||||
|
||||
self.ask_user_question_view = Some(view.clone());
|
||||
if self
|
||||
.action_model
|
||||
.as_ref(ctx)
|
||||
.get_action_status(self.client_ids.conversation_id, action_id)
|
||||
.is_some_and(|status| status.is_blocked())
|
||||
{
|
||||
if view.as_ref(ctx).is_waiting_on_user_answers(ctx) {
|
||||
ctx.focus(&view);
|
||||
}
|
||||
if self.sync_ask_user_question_speedbump_footer(ctx)
|
||||
@@ -4439,7 +4434,7 @@ impl AIBlock {
|
||||
}
|
||||
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
let login_command = ai_settings.bedrock_auth_refresh_command.value().clone();
|
||||
let login_command = crate::ai::provider::authentication::BedrockAuthConnection::current(ctx).command;
|
||||
// Recreating a historical error view must not restart authentication.
|
||||
let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value();
|
||||
|
||||
@@ -4452,7 +4447,7 @@ impl AIBlock {
|
||||
|
||||
let model_name = model_name.clone();
|
||||
let view = ctx.add_typed_action_view(|ctx| {
|
||||
AwsBedrockCredentialsErrorView::new(model_name, login_command, auto_login_enabled, ctx)
|
||||
AwsBedrockCredentialsErrorView::new(model_name, login_command, ctx)
|
||||
});
|
||||
|
||||
// Subscribe to events from the view and emit AIBlockEvents directly
|
||||
@@ -4826,16 +4821,20 @@ impl AIBlock {
|
||||
self.model.status(app).is_complete()
|
||||
}
|
||||
|
||||
/// Returns `true` if the block contains any actions that are blocked on user confirmation.
|
||||
/// Returns `true` when actions need confirmation or a question is waiting for an answer.
|
||||
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
|
||||
self.requested_action_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
self.action_model
|
||||
.as_ref(app)
|
||||
.get_action_status(self.client_ids.conversation_id, id)
|
||||
})
|
||||
.any(|status| status.is_blocked())
|
||||
self.ask_user_question_view
|
||||
.as_ref()
|
||||
.is_some_and(|view| view.as_ref(app).is_waiting_on_user_answers(app))
|
||||
|| self
|
||||
.requested_action_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
self.action_model
|
||||
.as_ref(app)
|
||||
.get_action_status(self.client_ids.conversation_id, id)
|
||||
})
|
||||
.any(|status| status.is_blocked())
|
||||
}
|
||||
|
||||
/// Returns the server output ID for this AI block, if available.
|
||||
@@ -5369,10 +5368,7 @@ impl AIBlock {
|
||||
did_focus_subview = true;
|
||||
} else if let Some(ask_user_question_view) =
|
||||
self.ask_user_question_view.as_ref().filter(|view| {
|
||||
pending_action_id.is_some_and(|id| {
|
||||
let view = view.as_ref(ctx);
|
||||
view.action_id() == id && view.is_editing()
|
||||
})
|
||||
view.as_ref(ctx).is_waiting_on_user_answers(ctx)
|
||||
})
|
||||
{
|
||||
ctx.focus(ask_user_question_view);
|
||||
|
||||
@@ -107,7 +107,7 @@ pub const WAITING_FOR_USER_INPUT_MESSAGE: &str = "Agent waiting for instructions
|
||||
const IMAGE_SOURCE_LINK_LINE_INDEX: usize = 1;
|
||||
|
||||
const ERROR_APOLOGY_TEXT: &str = "I'm sorry, I couldn't complete that request.";
|
||||
const INTERNAL_WARP_ERROR: &str = "Internal Warp error.";
|
||||
const INTERNAL_WARP_ERROR: &str = "Internal Galaxy error.";
|
||||
|
||||
const GALAXY_STATUS_MESSAGES: &[&str] = &[
|
||||
"Warping through the Galaxy...",
|
||||
@@ -3133,7 +3133,7 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
|
||||
|
||||
// While an automatic retry/resume is still in flight, don't surface the underlying
|
||||
// transport failure at all. These are typically transient and recover on their own,
|
||||
// so showing the alarming "Warp lost connection" banner (plus debug info) for every
|
||||
// so showing the alarming "Galaxy lost connection" banner (plus debug info) for every
|
||||
// blip is noisy and misleading. Render nothing during in-flight recovery; the full
|
||||
// error banner is only shown once recovery has actually failed. Dogfood builds
|
||||
// (Local/Dev) opt out so developers still see every transport failure aggressively.
|
||||
@@ -3160,7 +3160,7 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
|
||||
}
|
||||
}
|
||||
RenderableAIError::ServerOverloaded => {
|
||||
"Warp is currently overloaded. Please try again later.".to_string()
|
||||
"The AI service is currently overloaded. Please try again later.".to_string()
|
||||
}
|
||||
RenderableAIError::InternalWarpError => {
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{INTERNAL_WARP_ERROR}")
|
||||
|
||||
@@ -158,14 +158,24 @@ struct ActiveProviderRunIdentity {
|
||||
enum ActiveProviderProgressiveSummaryState {
|
||||
InFlight {
|
||||
identity: ActiveProviderRunIdentity,
|
||||
waiting_for_context: bool,
|
||||
},
|
||||
Ready {
|
||||
identity: ActiveProviderRunIdentity,
|
||||
waiting_for_context: bool,
|
||||
plan: ActiveProgressiveSummaryPlan,
|
||||
result: anyhow::Result<(String, Usage, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ActiveProviderProgressiveSummaryState {
|
||||
fn identity(&self) -> &ActiveProviderRunIdentity {
|
||||
match self {
|
||||
Self::InFlight { identity, .. } | Self::Ready { identity, .. } => identity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_llm_context_limit(info: &LLMInfo) -> u32 {
|
||||
[
|
||||
info.context_window.default_max,
|
||||
@@ -1029,6 +1039,7 @@ fn convert_provider_tool_batch(
|
||||
matches!(pending.state, PendingToolCallState::RecoveryPending),
|
||||
)),
|
||||
Err(message) => invalid_results.push(ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: pending.call.id.clone(),
|
||||
content: format!("Invalid {} tool input: {message}", pending.call.name),
|
||||
status: ToolResultStatus::Error,
|
||||
@@ -6202,6 +6213,24 @@ impl BlocklistAIController {
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
// Projection can be persisted before a mixed tool batch commits. Seed
|
||||
// from the actual visible list so replaying its inline update is a no-op.
|
||||
let projected_task_list = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|conversation| {
|
||||
crate::ai::runtime::task_list_from_api_tasks(
|
||||
&conversation.compute_active_tasks(),
|
||||
&root_task_id.to_string(),
|
||||
)
|
||||
});
|
||||
let projector = ProviderRunResponseProjector::restored(
|
||||
response_config.clone(),
|
||||
projection_was_initialized,
|
||||
)
|
||||
.with_restored_task_lists(
|
||||
projected_task_list,
|
||||
crate::ai::runtime::restored_task_list(coordinator.run()),
|
||||
);
|
||||
self.active_provider_runs.insert(
|
||||
conversation_id,
|
||||
ActiveProviderRunSlot {
|
||||
@@ -6213,10 +6242,7 @@ impl BlocklistAIController {
|
||||
projection_target,
|
||||
run: Some(ActiveProviderRun {
|
||||
coordinator,
|
||||
projector: ProviderRunResponseProjector::restored(
|
||||
response_config.clone(),
|
||||
projection_was_initialized,
|
||||
),
|
||||
projector,
|
||||
response_config,
|
||||
action_context,
|
||||
messages_sent,
|
||||
@@ -6610,7 +6636,11 @@ impl BlocklistAIController {
|
||||
let prepared = match result {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
self.fail_provider_startup(conversation_id, stream_id, error.to_string(), ctx);
|
||||
let renderable = crate::ai::provider::authentication::provider_startup_error(
|
||||
&error,
|
||||
slot.response_stream.as_ref(ctx).model_id(),
|
||||
);
|
||||
self.fail_provider_startup_with_error(conversation_id, stream_id, renderable, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -6667,9 +6697,11 @@ impl BlocklistAIController {
|
||||
.active_provider_runs
|
||||
.get(&conversation_id)
|
||||
.and_then(|slot| slot.cancellation_reason);
|
||||
let projector = ProviderRunResponseProjector::new(response_config.clone())
|
||||
.with_task_list(coordinator.run().task_list().cloned());
|
||||
let mut run = ActiveProviderRun {
|
||||
coordinator,
|
||||
projector: ProviderRunResponseProjector::new(response_config.clone()),
|
||||
projector,
|
||||
response_config,
|
||||
action_context,
|
||||
messages_sent,
|
||||
@@ -6931,8 +6963,9 @@ impl BlocklistAIController {
|
||||
}
|
||||
|
||||
let active_context_limit = active_run
|
||||
.response_config
|
||||
.max_context_tokens
|
||||
.coordinator
|
||||
.current_context_limit()
|
||||
.or(active_run.response_config.max_context_tokens)
|
||||
.unwrap_or_else(|| {
|
||||
let preferences = LLMPreferences::as_ref(ctx);
|
||||
configured_llm_context_limit(
|
||||
@@ -6950,16 +6983,21 @@ impl BlocklistAIController {
|
||||
conversation.has_pending_progressive_summary()
|
||||
};
|
||||
let transcript = active_run.coordinator.run().transcript();
|
||||
let Some((current_context_tokens, persistent_context_tokens)) =
|
||||
let Some((current_context_tokens, current_persistent_context_tokens)) =
|
||||
self.active_provider_context_estimate(conversation_id, ctx)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (summary_context_limit, summary_persistent_context_tokens) = active_run
|
||||
.coordinator
|
||||
.progressive_summary_context_budget()
|
||||
.unwrap_or((active_context_limit, current_persistent_context_tokens));
|
||||
// Trigger and retain against the space history can actually use. Fixed
|
||||
// prompts and a large output reservation cannot be reduced by summarizing.
|
||||
let history_capacity = active_context_limit.saturating_sub(persistent_context_tokens);
|
||||
let history_capacity =
|
||||
summary_context_limit.saturating_sub(summary_persistent_context_tokens);
|
||||
let current_history_tokens =
|
||||
current_context_tokens.saturating_sub(persistent_context_tokens);
|
||||
current_context_tokens.saturating_sub(current_persistent_context_tokens);
|
||||
if summary_pending
|
||||
|| last_failure_context_tokens == Some(current_context_tokens)
|
||||
|| (!force
|
||||
@@ -7025,7 +7063,7 @@ impl BlocklistAIController {
|
||||
retained_messages_count: transcript.len().saturating_sub(split_point),
|
||||
trigger_context_tokens: current_context_tokens,
|
||||
active_context_limit,
|
||||
persistent_context_tokens,
|
||||
persistent_context_tokens: current_persistent_context_tokens,
|
||||
candidates,
|
||||
summarized_prefix: transcript[..split_point].to_vec(),
|
||||
summarize_messages: vec![ConversationMessage {
|
||||
@@ -7046,6 +7084,7 @@ impl BlocklistAIController {
|
||||
conversation_id,
|
||||
ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: identity.clone(),
|
||||
waiting_for_context: force,
|
||||
},
|
||||
);
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
@@ -7116,27 +7155,23 @@ impl BlocklistAIController {
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let conversation_id = identity.conversation_id;
|
||||
let owns_summary = self
|
||||
let waiting_for_context = match self
|
||||
.active_provider_progressive_summaries
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|state| {
|
||||
matches!(
|
||||
state,
|
||||
ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: active_identity,
|
||||
} if active_identity == &identity
|
||||
)
|
||||
});
|
||||
if !owns_summary {
|
||||
return;
|
||||
}
|
||||
{
|
||||
Some(ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: active_identity,
|
||||
waiting_for_context,
|
||||
}) if active_identity == &identity => *waiting_for_context,
|
||||
_ => return,
|
||||
};
|
||||
let identity_is_current =
|
||||
self.active_provider_runs
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|slot| {
|
||||
slot.stream_id == identity.stream_id && slot.run_id == identity.run_id
|
||||
});
|
||||
if !identity_is_current {
|
||||
if !identity_is_current && self.active_provider_runs.contains_key(&conversation_id) {
|
||||
self.active_provider_progressive_summaries
|
||||
.remove(&conversation_id);
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
@@ -7151,11 +7186,29 @@ impl BlocklistAIController {
|
||||
conversation_id,
|
||||
ActiveProviderProgressiveSummaryState::Ready {
|
||||
identity,
|
||||
waiting_for_context,
|
||||
plan,
|
||||
result,
|
||||
},
|
||||
);
|
||||
self.drive_active_provider_run(conversation_id, ctx);
|
||||
if !self.active_provider_runs.contains_key(&conversation_id) {
|
||||
self.apply_ready_idle_provider_progressive_summary(conversation_id, ctx);
|
||||
return;
|
||||
}
|
||||
// A background result must not re-enter an active model call or dispatch
|
||||
// an awaiting tool batch again. Normal work consumes it at its next boundary.
|
||||
if waiting_for_context
|
||||
&& self.active_provider_runs.get(&conversation_id).is_some_and(|slot| {
|
||||
slot.run.as_ref().is_some_and(|run| {
|
||||
matches!(
|
||||
run.coordinator.run().state(),
|
||||
ProviderRunState::ReadyToCallModel
|
||||
)
|
||||
})
|
||||
})
|
||||
{
|
||||
self.drive_active_provider_run(conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_ready_active_provider_progressive_summary(
|
||||
@@ -7188,6 +7241,7 @@ impl BlocklistAIController {
|
||||
|
||||
let Some(ActiveProviderProgressiveSummaryState::Ready {
|
||||
identity: _,
|
||||
waiting_for_context,
|
||||
plan,
|
||||
result,
|
||||
}) = self
|
||||
@@ -7204,6 +7258,7 @@ impl BlocklistAIController {
|
||||
|
||||
let mut summary_applied = false;
|
||||
let mut recorded_usage = None;
|
||||
let mut failure = None;
|
||||
match result {
|
||||
Ok((summary, usage, summarizer_model_id))
|
||||
if estimated_history_tokens(&progressive_summary_messages(summary.clone()))
|
||||
@@ -7213,6 +7268,7 @@ impl BlocklistAIController {
|
||||
"[progressive-summary] Summary did not reduce context for conversation {:?}",
|
||||
conversation_id,
|
||||
);
|
||||
failure = Some("summary did not reduce context".to_string());
|
||||
recorded_usage = Some((usage, summarizer_model_id));
|
||||
}
|
||||
Ok((summary, usage, summarizer_model_id)) => {
|
||||
@@ -7229,6 +7285,9 @@ impl BlocklistAIController {
|
||||
"[progressive-summary] Discarding stale background summary for active conversation {:?}; the snapshotted prefix no longer matches",
|
||||
conversation_id,
|
||||
);
|
||||
failure = Some(
|
||||
"summary prefix no longer matches conversation history".to_string(),
|
||||
);
|
||||
} else {
|
||||
let drained = transcript[..plan.split_point].to_vec();
|
||||
let retained = transcript[plan.split_point..].to_vec();
|
||||
@@ -7236,6 +7295,14 @@ impl BlocklistAIController {
|
||||
let summary_prefix = progressive_summary_messages(summary.clone());
|
||||
let summary_prefix_len = summary_prefix.len();
|
||||
let summary_prefix_tokens = estimated_history_tokens(&summary_prefix);
|
||||
let persistent_context_tokens = run
|
||||
.coordinator
|
||||
.estimated_context_tokens()
|
||||
.map_or(plan.persistent_context_tokens, |(_, fixed)| fixed);
|
||||
let active_context_limit = run
|
||||
.coordinator
|
||||
.current_context_limit()
|
||||
.unwrap_or(plan.active_context_limit);
|
||||
if let Err(error) = run
|
||||
.coordinator
|
||||
.run_mut()
|
||||
@@ -7250,12 +7317,11 @@ impl BlocklistAIController {
|
||||
if let Ok(mut messages_sent) = run.messages_sent.lock() {
|
||||
*messages_sent = retained.clone();
|
||||
}
|
||||
let new_context_tokens = plan
|
||||
.persistent_context_tokens
|
||||
let new_context_tokens = persistent_context_tokens
|
||||
.saturating_add(summary_prefix_tokens)
|
||||
.saturating_add(estimated_history_tokens(&retained));
|
||||
let new_usage =
|
||||
new_context_tokens as f32 / plan.active_context_limit.max(1) as f32;
|
||||
new_context_tokens as f32 / active_context_limit.max(1) as f32;
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
let Some(conversation) = history.conversation_mut(&conversation_id)
|
||||
else {
|
||||
@@ -7273,7 +7339,7 @@ impl BlocklistAIController {
|
||||
summarizer_model_id,
|
||||
new_context_tokens,
|
||||
new_usage * 100.0,
|
||||
plan.active_context_limit,
|
||||
active_context_limit,
|
||||
retained_count,
|
||||
);
|
||||
recorded_usage = Some((usage, summarizer_model_id));
|
||||
@@ -7286,9 +7352,13 @@ impl BlocklistAIController {
|
||||
"[progressive-summary] Failed for active conversation {:?}: {error:#}",
|
||||
conversation_id,
|
||||
);
|
||||
failure = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let failure_context_tokens = self
|
||||
.active_provider_context_estimate(conversation_id, ctx)
|
||||
.map_or(plan.trigger_context_tokens, |(tokens, _)| tokens);
|
||||
if let Some(run) = self
|
||||
.active_provider_runs
|
||||
.get_mut(&conversation_id)
|
||||
@@ -7298,12 +7368,23 @@ impl BlocklistAIController {
|
||||
None
|
||||
} else {
|
||||
// Fail open and suppress another request at this unchanged context boundary.
|
||||
Some(plan.trigger_context_tokens)
|
||||
Some(failure_context_tokens)
|
||||
};
|
||||
}
|
||||
if let Some((usage, summarizer_model_id)) = recorded_usage {
|
||||
self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx);
|
||||
}
|
||||
if waiting_for_context && !summary_applied {
|
||||
self.fail_active_provider_run(
|
||||
conversation_id,
|
||||
format!(
|
||||
"context compaction could not recover enough space: {}",
|
||||
failure.unwrap_or_else(|| "could not replace the summarized history".to_string())
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
|
||||
self.fail_active_provider_run(
|
||||
conversation_id,
|
||||
@@ -7459,18 +7540,55 @@ impl BlocklistAIController {
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// A follow-up owns a new generation. Its history must never be blocked
|
||||
// by, or replaced with, a detached summary from the previous generation.
|
||||
// Cancellation also discards summaries before they can fail recovery.
|
||||
let stale_summary = self
|
||||
.active_provider_progressive_summaries
|
||||
.get(&conversation_id)
|
||||
.zip(self.active_provider_runs.get(&conversation_id))
|
||||
.is_some_and(|(summary, slot)| {
|
||||
let identity = summary.identity();
|
||||
slot.stream_id != identity.stream_id || slot.run_id != identity.run_id
|
||||
});
|
||||
let cancelling = self
|
||||
.active_provider_runs
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|slot| slot.cancellation_reason.is_some());
|
||||
if (stale_summary || cancelling)
|
||||
&& self
|
||||
.active_provider_progressive_summaries
|
||||
.remove(&conversation_id)
|
||||
.is_some()
|
||||
{
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||
conversation.set_has_pending_progressive_summary(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if self.apply_ready_active_provider_progressive_summary(conversation_id, ctx) {
|
||||
return;
|
||||
}
|
||||
self.schedule_provider_command_output_poll(conversation_id, ctx);
|
||||
self.begin_active_provider_progressive_summary(conversation_id, false, ctx);
|
||||
// Ordinary background work never blocks the agent. Once a model has
|
||||
// exhausted its context, polling must not relaunch that same request
|
||||
// while its recovery summary is still running. Cancellation still drives.
|
||||
if matches!(
|
||||
self.active_provider_progressive_summaries
|
||||
.get(&conversation_id),
|
||||
Some(ActiveProviderProgressiveSummaryState::InFlight { .. })
|
||||
) {
|
||||
self.active_provider_progressive_summaries.get(&conversation_id),
|
||||
Some(ActiveProviderProgressiveSummaryState::InFlight {
|
||||
waiting_for_context: true,
|
||||
..
|
||||
})
|
||||
) && self.active_provider_runs.get(&conversation_id).is_some_and(|slot| {
|
||||
slot.cancellation_reason.is_none()
|
||||
&& slot.run.as_ref().is_some_and(|run| {
|
||||
matches!(run.coordinator.run().state(), ProviderRunState::ReadyToCallModel)
|
||||
})
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.begin_active_provider_progressive_summary(conversation_id, false, ctx);
|
||||
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -7972,6 +8090,24 @@ impl BlocklistAIController {
|
||||
);
|
||||
return;
|
||||
}
|
||||
match self.active_provider_progressive_summaries.get_mut(&conversation_id) {
|
||||
Some(ActiveProviderProgressiveSummaryState::InFlight {
|
||||
waiting_for_context,
|
||||
..
|
||||
}) => {
|
||||
*waiting_for_context = true;
|
||||
return;
|
||||
}
|
||||
Some(ActiveProviderProgressiveSummaryState::Ready {
|
||||
waiting_for_context,
|
||||
..
|
||||
}) => {
|
||||
*waiting_for_context = true;
|
||||
self.drive_active_provider_run(conversation_id, ctx);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if !self.begin_active_provider_progressive_summary(conversation_id, true, ctx) {
|
||||
self.fail_active_provider_run(
|
||||
conversation_id,
|
||||
@@ -8325,7 +8461,10 @@ impl BlocklistAIController {
|
||||
matches!(
|
||||
pending.call.name.as_str(),
|
||||
"request_computer_use" | "use_computer"
|
||||
)
|
||||
) && pending.state.result().or_else(|| match event {
|
||||
galaxy_agent_core::ToolEvent::Completed { result } if result.call_id == pending.call.id => Some(result),
|
||||
_ => None,
|
||||
}).is_some_and(|result| result.status == galaxy_agent_core::ToolResultStatus::Success)
|
||||
});
|
||||
used_computer.then_some(COMPUTER_USE_PROVIDER_PROFILE)
|
||||
}
|
||||
@@ -8821,6 +8960,11 @@ impl BlocklistAIController {
|
||||
&outcome,
|
||||
),
|
||||
);
|
||||
let completed_cleanly = matches!(
|
||||
&outcome,
|
||||
ProviderRunOutcome::Completed(completion)
|
||||
if completion.stop_reason == StopReason::Completed
|
||||
);
|
||||
match outcome {
|
||||
ProviderRunOutcome::Completed(completion) => match completion.stop_reason {
|
||||
StopReason::Completed => {
|
||||
@@ -8885,7 +9029,140 @@ impl BlocklistAIController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A background checkpoint remains useful after the answer finishes. Detach
|
||||
// it while cleanup releases this generation, then retain it only if no
|
||||
// successor has started or claimed its own summary in the meantime.
|
||||
let pending_summary = if completed_cleanly {
|
||||
self.active_provider_progressive_summaries
|
||||
.remove(&conversation_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.cleanup_active_provider_run(conversation_id, &stream_id, &response_stream, ctx);
|
||||
if let Some(summary) = pending_summary {
|
||||
let conversation_succeeded = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation_status(&conversation_id)
|
||||
== Some(&ConversationStatus::Success);
|
||||
if conversation_succeeded
|
||||
&& !self.active_provider_runs.contains_key(&conversation_id)
|
||||
&& !self
|
||||
.active_provider_progressive_summaries
|
||||
.contains_key(&conversation_id)
|
||||
{
|
||||
self.active_provider_progressive_summaries
|
||||
.insert(conversation_id, summary);
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||
conversation.set_has_pending_progressive_summary(true);
|
||||
}
|
||||
});
|
||||
self.apply_ready_idle_provider_progressive_summary(conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_ready_idle_provider_progressive_summary(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.active_provider_runs.contains_key(&conversation_id)
|
||||
|| !matches!(
|
||||
self.active_provider_progressive_summaries
|
||||
.get(&conversation_id),
|
||||
Some(ActiveProviderProgressiveSummaryState::Ready { .. })
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(ActiveProviderProgressiveSummaryState::Ready { plan, result, .. }) = self
|
||||
.active_provider_progressive_summaries
|
||||
.remove(&conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||
conversation.set_has_pending_progressive_summary(false);
|
||||
}
|
||||
});
|
||||
if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id)
|
||||
!= Some(&ConversationStatus::Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok((summary, usage, summarizer_model_id)) => {
|
||||
let summary_prefix_tokens =
|
||||
estimated_history_tokens(&progressive_summary_messages(summary.clone()));
|
||||
let summary_reduces_context =
|
||||
summary_prefix_tokens < estimated_history_tokens(&plan.summarized_prefix);
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let Some(conversation) = history.conversation_mut(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let mut transcript = conversation
|
||||
.progressive_summary()
|
||||
.map(|existing| progressive_summary_messages(existing.to_owned()))
|
||||
.unwrap_or_default();
|
||||
transcript.extend_from_slice(conversation.bedrock_message_history());
|
||||
if transcript.get(..plan.split_point)
|
||||
!= Some(plan.summarized_prefix.as_slice())
|
||||
{
|
||||
log::warn!(
|
||||
"[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches",
|
||||
conversation_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !summary_reduces_context {
|
||||
log::warn!(
|
||||
"[progressive-summary] Idle summary did not reduce context for conversation {:?}",
|
||||
conversation_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let retained = transcript.split_off(plan.split_point);
|
||||
let retained_count = retained.len();
|
||||
let new_context_tokens = plan
|
||||
.persistent_context_tokens
|
||||
.saturating_add(summary_prefix_tokens)
|
||||
.saturating_add(estimated_history_tokens(&retained));
|
||||
let new_usage =
|
||||
new_context_tokens as f32 / plan.active_context_limit.max(1) as f32;
|
||||
conversation.archive_tool_results(transcript);
|
||||
*conversation.bedrock_message_history_mut() = retained;
|
||||
conversation.set_progressive_summary(Some(summary), 0);
|
||||
conversation.set_current_context_tokens(new_context_tokens);
|
||||
conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0));
|
||||
conversation.write_updated_conversation_state(ctx);
|
||||
log::info!(
|
||||
"[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved",
|
||||
conversation_id,
|
||||
new_context_tokens,
|
||||
new_usage * 100.0,
|
||||
plan.active_context_limit,
|
||||
retained_count,
|
||||
);
|
||||
});
|
||||
// This Ready result is consumed once, even when its prefix became
|
||||
// stale or its response could not reduce the retained context.
|
||||
self.record_progressive_summary_usage(
|
||||
conversation_id,
|
||||
usage,
|
||||
summarizer_model_id,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"[progressive-summary] Failed after active conversation {:?} completed: {error:#}",
|
||||
conversation_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_failed_provider_conversation(
|
||||
@@ -8916,6 +9193,21 @@ impl BlocklistAIController {
|
||||
stream_id: ResponseStreamId,
|
||||
message: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.fail_provider_startup_with_error(
|
||||
conversation_id,
|
||||
stream_id,
|
||||
RenderableAIError::other(message, false),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
fn fail_provider_startup_with_error(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
stream_id: ResponseStreamId,
|
||||
error: RenderableAIError,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let response_stream = self
|
||||
.active_provider_runs
|
||||
@@ -8925,14 +9217,10 @@ impl BlocklistAIController {
|
||||
let Some(response_stream) = response_stream else {
|
||||
return;
|
||||
};
|
||||
let message = error.to_string();
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||
history_model.mark_response_stream_completed_with_error(
|
||||
RenderableAIError::Other {
|
||||
error_message: message.clone(),
|
||||
will_attempt_resume: false,
|
||||
waiting_for_network: false,
|
||||
is_user_error: false,
|
||||
},
|
||||
error,
|
||||
false,
|
||||
&stream_id,
|
||||
conversation_id,
|
||||
|
||||
@@ -187,30 +187,14 @@ impl ResponseStream {
|
||||
|
||||
// Fall back to Bedrock
|
||||
if *settings.bedrock_enabled.value() {
|
||||
let auth_method = *settings.bedrock_auth_method.value();
|
||||
let region = settings.bedrock_region.value().clone();
|
||||
let cross_region_inference = *settings.bedrock_cross_region_inference.value();
|
||||
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
|
||||
let mut config = BedrockClientConfig {
|
||||
auth_method,
|
||||
profile: settings.bedrock_profile.value().clone(),
|
||||
region,
|
||||
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
||||
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
||||
session_token: None,
|
||||
cross_region_inference,
|
||||
};
|
||||
|
||||
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
|
||||
api_key_manager.aws_credentials_state()
|
||||
{
|
||||
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
|
||||
config.access_key_id = credentials.access_key().to_string();
|
||||
config.secret_access_key = credentials.secret_key().to_string();
|
||||
config.session_token = credentials.session_token().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
return ProviderConfig::Bedrock(config.with_external_fallbacks());
|
||||
let config = BedrockClientConfig::from_settings(settings)
|
||||
.with_model_route(model_id, settings.bedrock_models.value())
|
||||
.with_managed_credentials(
|
||||
&api_key_manager.aws_credentials_refresh_strategy(),
|
||||
api_key_manager.aws_credentials_state(),
|
||||
);
|
||||
return ProviderConfig::Bedrock(config);
|
||||
}
|
||||
|
||||
ProviderConfig::None
|
||||
|
||||
@@ -4,16 +4,16 @@ use std::sync::{Arc, Mutex};
|
||||
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionType};
|
||||
use chrono::Local;
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, CompletedModelTurn, ContentPart, ConversationMessage,
|
||||
turn_control, AgentError, AgentErrorKind, CompletedModelTurn, ContentPart, ConversationMessage,
|
||||
ExternalWorkId, MessageContent, MessageRole, PermissionKind, PermissionRequest, ProviderRun,
|
||||
ProviderRunFailure, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits,
|
||||
ProviderRunOutcome, ProviderRunState, ProviderRunStep, RunEpoch, RuntimeCapabilities,
|
||||
StopReason, ToolCall, TurnRequest, Usage,
|
||||
StopReason, ToolCall, TurnCommand, TurnRequest, Usage,
|
||||
};
|
||||
use galaxy_core::command::ExitCode;
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event;
|
||||
use warpui::{App, EntityId, SingletonEntity};
|
||||
use warpui::{App, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
@@ -3915,6 +3915,387 @@ fn optimistic_cli_subagent_completion_with_in_flight_stream_reports_success() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_completion_preserves_an_in_flight_model_turn() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
|
||||
terminal.update(&mut app, |terminal, ctx| {
|
||||
let conversation_id =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal.id(), false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.unwrap()
|
||||
.set_has_pending_progressive_summary(true);
|
||||
conversation_id
|
||||
});
|
||||
let stream_id = ResponseStreamId::new_for_test();
|
||||
let response_stream =
|
||||
ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
|
||||
let mut slot = progressive_summary_test_slot(conversation_id, stream_id, response_stream);
|
||||
let identity = super::ActiveProviderRunIdentity {
|
||||
conversation_id,
|
||||
stream_id: slot.stream_id.clone(),
|
||||
run_id: slot.run_id.clone(),
|
||||
};
|
||||
let (sender, foreground_control) = turn_control();
|
||||
slot.turn_control = Some(sender);
|
||||
let prefix = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("old project details".repeat(1_000)),
|
||||
}];
|
||||
|
||||
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller.active_provider_runs.insert(conversation_id, slot);
|
||||
controller.active_provider_progressive_summaries.insert(
|
||||
conversation_id,
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: identity.clone(),
|
||||
waiting_for_context: true,
|
||||
},
|
||||
);
|
||||
|
||||
controller.finish_active_provider_progressive_summary(
|
||||
identity.clone(),
|
||||
progressive_summary_test_plan(prefix),
|
||||
Ok((
|
||||
"checkpoint".to_owned(),
|
||||
Usage::default(),
|
||||
"summary-model".to_owned(),
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let active = &controller.active_provider_runs[&conversation_id];
|
||||
assert_eq!(active.stream_id, identity.stream_id);
|
||||
assert_eq!(active.run_id, identity.run_id);
|
||||
assert!(active.run.is_none());
|
||||
assert!(active.checkpoint.is_none());
|
||||
assert!(active.cancellation_reason.is_none());
|
||||
let steer = TurnCommand::Steer {
|
||||
display_text: "continue".to_owned(),
|
||||
model_text: "continue".to_owned(),
|
||||
};
|
||||
assert!(foreground_control.try_receive().is_err());
|
||||
active
|
||||
.turn_control
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.try_send(steer.clone())
|
||||
.unwrap();
|
||||
assert_eq!(foreground_control.try_receive().unwrap(), steer);
|
||||
match &controller.active_provider_progressive_summaries[&conversation_id] {
|
||||
super::ActiveProviderProgressiveSummaryState::Ready {
|
||||
identity: ready_identity,
|
||||
waiting_for_context,
|
||||
result,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(ready_identity.run_id, identity.run_id);
|
||||
assert!(*waiting_for_context);
|
||||
assert_eq!(result.as_ref().unwrap().0, "checkpoint");
|
||||
}
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight { .. } => {
|
||||
panic!("completed summary must remain ready until the next model boundary");
|
||||
}
|
||||
}
|
||||
});
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.unwrap();
|
||||
assert!(conversation.has_pending_progressive_summary());
|
||||
assert_eq!(conversation.progressive_summary(), None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_progressive_summary_completion_preserves_a_successor_generation() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
|
||||
terminal.update(&mut app, |terminal, ctx| {
|
||||
let conversation_id =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal.id(), false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.unwrap()
|
||||
.set_has_pending_progressive_summary(true);
|
||||
conversation_id
|
||||
});
|
||||
let stream_id = ResponseStreamId::new_for_test();
|
||||
let response_stream =
|
||||
ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
|
||||
let slot = progressive_summary_test_slot(conversation_id, stream_id, response_stream);
|
||||
let identity = super::ActiveProviderRunIdentity {
|
||||
conversation_id,
|
||||
stream_id: slot.stream_id.clone(),
|
||||
run_id: slot.run_id.clone(),
|
||||
};
|
||||
let stale_identity = super::ActiveProviderRunIdentity {
|
||||
conversation_id,
|
||||
stream_id: ResponseStreamId::new_for_test(),
|
||||
run_id: ProviderRunId::new("previous-generation"),
|
||||
};
|
||||
|
||||
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller.active_provider_runs.insert(conversation_id, slot);
|
||||
controller.active_provider_progressive_summaries.insert(
|
||||
conversation_id,
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: identity.clone(),
|
||||
waiting_for_context: false,
|
||||
},
|
||||
);
|
||||
|
||||
controller.finish_active_provider_progressive_summary(
|
||||
stale_identity,
|
||||
progressive_summary_test_plan(Vec::new()),
|
||||
Ok((
|
||||
"stale checkpoint".to_owned(),
|
||||
Usage::default(),
|
||||
"summary-model".to_owned(),
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let active = &controller.active_provider_runs[&conversation_id];
|
||||
assert_eq!(active.run_id, identity.run_id);
|
||||
assert_eq!(active.stream_id, identity.stream_id);
|
||||
match &controller.active_provider_progressive_summaries[&conversation_id] {
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: pending_identity,
|
||||
waiting_for_context,
|
||||
} => {
|
||||
assert_eq!(pending_identity.run_id, identity.run_id);
|
||||
assert_eq!(pending_identity.stream_id, identity.stream_id);
|
||||
assert!(!*waiting_for_context);
|
||||
}
|
||||
super::ActiveProviderProgressiveSummaryState::Ready { .. } => {
|
||||
panic!("stale completion must not replace the successor summary");
|
||||
}
|
||||
}
|
||||
});
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.unwrap();
|
||||
assert!(conversation.has_pending_progressive_summary());
|
||||
assert_eq!(conversation.progressive_summary(), None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_progressive_summary_preserves_appended_messages_and_archives_tool_results() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
|
||||
terminal.update(&mut app, |terminal, ctx| {
|
||||
let prefix = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: "read-original".to_owned(),
|
||||
name: "read_file".to_owned(),
|
||||
input: serde_json::json!({"path": "src/original.rs"}),
|
||||
},
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: "read-original".to_owned(),
|
||||
content: "original file contents".repeat(1_000),
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
let tail = vec![
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text("The change is ready".to_owned()),
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Appended while the summary was running".to_owned()),
|
||||
},
|
||||
];
|
||||
let conversation_id =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal.id(), false, false, false, ctx);
|
||||
let conversation = history.conversation_mut(&conversation_id).unwrap();
|
||||
conversation.append_to_bedrock_history(prefix.clone());
|
||||
conversation.append_to_bedrock_history(tail.clone());
|
||||
conversation.set_has_pending_progressive_summary(true);
|
||||
conversation.set_status_for_test(ConversationStatus::Success);
|
||||
conversation_id
|
||||
});
|
||||
let identity = super::ActiveProviderRunIdentity {
|
||||
conversation_id,
|
||||
stream_id: ResponseStreamId::new_for_test(),
|
||||
run_id: ProviderRunId::new("finished-generation"),
|
||||
};
|
||||
|
||||
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller.active_provider_progressive_summaries.insert(
|
||||
conversation_id,
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: identity.clone(),
|
||||
waiting_for_context: false,
|
||||
},
|
||||
);
|
||||
controller.finish_active_provider_progressive_summary(
|
||||
identity,
|
||||
progressive_summary_test_plan(prefix.clone()),
|
||||
Ok((
|
||||
"checkpoint".to_owned(),
|
||||
Usage::default(),
|
||||
"summary-model".to_owned(),
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
assert!(!controller.active_provider_runs.contains_key(&conversation_id));
|
||||
assert!(!controller
|
||||
.active_provider_progressive_summaries
|
||||
.contains_key(&conversation_id));
|
||||
});
|
||||
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.unwrap();
|
||||
assert_eq!(conversation.progressive_summary(), Some("checkpoint"));
|
||||
assert_eq!(conversation.bedrock_message_history(), tail.as_slice());
|
||||
assert_eq!(conversation.tool_result_archive(), prefix.as_slice());
|
||||
assert!(!conversation.has_pending_progressive_summary());
|
||||
assert_eq!(conversation.status(), &ConversationStatus::Success);
|
||||
assert_eq!(
|
||||
conversation.current_context_tokens(),
|
||||
100 + super::estimated_history_tokens(&super::progressive_summary_messages(
|
||||
"checkpoint".to_owned()
|
||||
)) + super::estimated_history_tokens(&tail)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_progressive_summary_rejects_a_changed_prefix() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
|
||||
terminal.update(&mut app, |terminal, ctx| {
|
||||
let prefix = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("original project context".repeat(1_000)),
|
||||
}];
|
||||
let replacement = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("replacement context that must survive".repeat(1_000)),
|
||||
}];
|
||||
let conversation_id =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal.id(), false, false, false, ctx);
|
||||
let conversation = history.conversation_mut(&conversation_id).unwrap();
|
||||
conversation.append_to_bedrock_history(replacement.clone());
|
||||
conversation.set_has_pending_progressive_summary(true);
|
||||
conversation.set_status_for_test(ConversationStatus::Success);
|
||||
conversation_id
|
||||
});
|
||||
let identity = super::ActiveProviderRunIdentity {
|
||||
conversation_id,
|
||||
stream_id: ResponseStreamId::new_for_test(),
|
||||
run_id: ProviderRunId::new("finished-generation"),
|
||||
};
|
||||
|
||||
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller.active_provider_progressive_summaries.insert(
|
||||
conversation_id,
|
||||
super::ActiveProviderProgressiveSummaryState::InFlight {
|
||||
identity: identity.clone(),
|
||||
waiting_for_context: false,
|
||||
},
|
||||
);
|
||||
controller.finish_active_provider_progressive_summary(
|
||||
identity,
|
||||
progressive_summary_test_plan(prefix),
|
||||
Ok((
|
||||
"stale checkpoint".to_owned(),
|
||||
Usage::default(),
|
||||
"summary-model".to_owned(),
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
assert!(!controller
|
||||
.active_provider_progressive_summaries
|
||||
.contains_key(&conversation_id));
|
||||
});
|
||||
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.unwrap();
|
||||
assert_eq!(conversation.progressive_summary(), None);
|
||||
assert_eq!(conversation.bedrock_message_history(), replacement.as_slice());
|
||||
assert!(conversation.tool_result_archive().is_empty());
|
||||
assert!(!conversation.has_pending_progressive_summary());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn progressive_summary_test_plan(
|
||||
summarized_prefix: Vec<ConversationMessage>,
|
||||
) -> super::ActiveProgressiveSummaryPlan {
|
||||
super::ActiveProgressiveSummaryPlan {
|
||||
split_point: summarized_prefix.len(),
|
||||
retained_messages_count: 1,
|
||||
trigger_context_tokens: 10_000,
|
||||
active_context_limit: 128_000,
|
||||
persistent_context_tokens: 100,
|
||||
candidates: Vec::new(),
|
||||
summarize_messages: summarized_prefix.clone(),
|
||||
summarized_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
fn progressive_summary_test_slot(
|
||||
conversation_id: AIConversationId,
|
||||
stream_id: ResponseStreamId,
|
||||
response_stream: ModelHandle<ResponseStream>,
|
||||
) -> super::ActiveProviderRunSlot {
|
||||
let snapshot = provider_snapshot(conversation_id);
|
||||
super::ActiveProviderRunSlot {
|
||||
stream_id,
|
||||
response_stream,
|
||||
did_input_contain_user_query: true,
|
||||
run_id: snapshot.run.id().clone(),
|
||||
root_task_id: snapshot.root_task_id,
|
||||
projection_target: snapshot.projection_target,
|
||||
run: None,
|
||||
checkpoint: None,
|
||||
turn_control: None,
|
||||
cancellation_reason: None,
|
||||
committed_provider_batch: None,
|
||||
finished_provider_batch: None,
|
||||
command_action_refs: HashMap::new(),
|
||||
command_monitor: None,
|
||||
pending_monitor_observation: None,
|
||||
pending_command_completion: None,
|
||||
monitor_prose_continuations: 0,
|
||||
live_command_output_tokens: 0,
|
||||
command_output_poll_scheduled: false,
|
||||
retry_status: None,
|
||||
tool_call_progress: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_compacts_short_histories_to_the_token_budget() {
|
||||
let messages = vec![
|
||||
|
||||
@@ -226,6 +226,22 @@ impl AIConversationMetadata {
|
||||
}
|
||||
|
||||
impl BlocklistAIHistoryModel {
|
||||
/// Restore saved local sessions without falling back to a hosted transcript.
|
||||
pub fn load_local_conversation_data(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
) -> galaxyui::r#async::BoxFuture<'static, Option<CloudConversationData>> {
|
||||
let conversation = self
|
||||
.load_conversation_from_db(&conversation_id)
|
||||
.filter(|conversation| {
|
||||
!conversation
|
||||
.server_metadata()
|
||||
.is_some_and(|metadata| metadata.ambient_agent_task_id.is_some())
|
||||
})
|
||||
.map(|conversation| CloudConversationData::Oz(Box::new(conversation)));
|
||||
box_future(futures::future::ready(conversation))
|
||||
}
|
||||
|
||||
/// Loads conversation data from the appropriate source (DB or server).
|
||||
///
|
||||
/// This method automatically determines whether to load from the local database or
|
||||
|
||||
@@ -188,6 +188,17 @@ enum AskUserQuestionPhase<'a> {
|
||||
},
|
||||
}
|
||||
|
||||
fn ask_user_question_is_waiting_on_user_answers(
|
||||
phase: AskUserQuestionPhase<'_>,
|
||||
status: Option<&AIActionStatus>,
|
||||
) -> bool {
|
||||
matches!(phase, AskUserQuestionPhase::Editing)
|
||||
&& matches!(
|
||||
status,
|
||||
Some(AIActionStatus::Blocked | AIActionStatus::RunningAsync)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
/// Editing-phase state for the questionnaire.
|
||||
/// Holds the active question index and one draft slot per question.
|
||||
@@ -806,10 +817,6 @@ impl AskUserQuestionView {
|
||||
&self.action_id
|
||||
}
|
||||
|
||||
pub fn is_editing(&self) -> bool {
|
||||
self.session.is_editing()
|
||||
}
|
||||
|
||||
/// Sets the settings-link mouse handle used in the speedbump footer.
|
||||
/// The footer is only actually drawn during the completed/finished render paths.
|
||||
pub fn set_speedbump_settings_link(
|
||||
@@ -902,10 +909,12 @@ impl AskUserQuestionView {
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the action model reports this inline action is blocked waiting on user answers.
|
||||
fn is_waiting_on_user_answers(&self, app: &AppContext) -> bool {
|
||||
self.action_status(app)
|
||||
.is_some_and(|status| status.is_blocked())
|
||||
/// Questions can wait before execution or in an approved async executor, as in Allow All.
|
||||
pub(crate) fn is_waiting_on_user_answers(&self, app: &AppContext) -> bool {
|
||||
ask_user_question_is_waiting_on_user_answers(
|
||||
self.session.phase(),
|
||||
self.action_status(app).as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn should_render_inline(&self, app: &AppContext) -> bool {
|
||||
@@ -1170,8 +1179,8 @@ impl AskUserQuestionView {
|
||||
self.abort_auto_advance();
|
||||
let action_id = self.action_id().clone();
|
||||
let conversation_id = self.conversation_id;
|
||||
// Completing the executor only stages the result; execute_action drives the action model
|
||||
// to publish final status/events for this inline block.
|
||||
// A running executor receives the answer directly. A blocked question must first start
|
||||
// execution; execute_action starts it and leaves an already-running receiver intact.
|
||||
self.action_model.update(ctx, |action_model, ctx| {
|
||||
action_model
|
||||
.ask_user_question_executor(ctx)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType};
|
||||
use ai::agent::action_result::AskUserQuestionAnswerItem;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
|
||||
use super::{
|
||||
ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect,
|
||||
AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState,
|
||||
ask_user_question_is_waiting_on_user_answers, ask_user_question_view_state,
|
||||
AskUserQuestionAction, AskUserQuestionEffect, AskUserQuestionPhase,
|
||||
AskUserQuestionSession, AskUserQuestionViewState,
|
||||
};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentActionResult, AIAgentActionResultType};
|
||||
use crate::ai::blocklist::action_model::AIActionStatus;
|
||||
|
||||
fn build_question(
|
||||
question_id: &str,
|
||||
@@ -42,6 +48,53 @@ fn current_draft(session: &AskUserQuestionSession) -> Option<&super::QuestionDra
|
||||
session.current().and_then(|current| current.draft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_and_running_questions_accept_answers_until_the_session_completes() {
|
||||
let mut session = build_session(vec![build_question("q1", "Destination", false, true, &[])]);
|
||||
let blocked = AIActionStatus::Blocked;
|
||||
let running = AIActionStatus::RunningAsync;
|
||||
|
||||
for status in [&blocked, &running] {
|
||||
assert!(ask_user_question_is_waiting_on_user_answers(
|
||||
session.phase(),
|
||||
Some(status),
|
||||
));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
session.apply(AskUserQuestionAction::SkipAll),
|
||||
AskUserQuestionEffect::Submit(_)
|
||||
));
|
||||
for status in [&blocked, &running] {
|
||||
assert!(!ask_user_question_is_waiting_on_user_answers(
|
||||
session.phase(),
|
||||
Some(status),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_preprocessing_missing_and_finished_questions_do_not_accept_answers() {
|
||||
let session = build_session(vec![build_question("q1", "Destination", false, true, &[])]);
|
||||
let finished = AIActionStatus::Finished(Arc::new(AIAgentActionResult {
|
||||
result: AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled),
|
||||
task_id: TaskId::new("question-task".to_owned()),
|
||||
id: AIAgentActionId::from("question-action".to_owned()),
|
||||
}));
|
||||
|
||||
for status in [
|
||||
None,
|
||||
Some(AIActionStatus::Queued),
|
||||
Some(AIActionStatus::Preprocessing),
|
||||
Some(finished),
|
||||
] {
|
||||
assert!(!ask_user_question_is_waiting_on_user_answers(
|
||||
session.phase(),
|
||||
status.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_other_row_focuses_the_other_input() {
|
||||
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
|
||||
|
||||
@@ -35,8 +35,6 @@ pub struct AwsBedrockCredentialsErrorView {
|
||||
model_name: String,
|
||||
login_command: String,
|
||||
|
||||
/// Whether auto-login was triggered, which shows a simpler "running command" message.
|
||||
auto_login_triggered: bool,
|
||||
|
||||
// Run button
|
||||
run_button: ViewHandle<ActionButton>,
|
||||
@@ -52,7 +50,6 @@ impl AwsBedrockCredentialsErrorView {
|
||||
pub fn new(
|
||||
model_name: String,
|
||||
login_command: String,
|
||||
auto_login_triggered: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Run button
|
||||
@@ -83,7 +80,6 @@ impl AwsBedrockCredentialsErrorView {
|
||||
Self {
|
||||
model_name,
|
||||
login_command,
|
||||
auto_login_triggered,
|
||||
run_button,
|
||||
configure_button,
|
||||
auto_login_checkbox_handle: MouseStateHandle::default(),
|
||||
@@ -104,23 +100,6 @@ impl View for AwsBedrockCredentialsErrorView {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// If auto-login was triggered, show a simple "running command" message
|
||||
if self.auto_login_triggered {
|
||||
return Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new(
|
||||
format!("Running `{}`...", self.login_command),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
}
|
||||
|
||||
let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value();
|
||||
|
||||
@@ -149,11 +128,19 @@ impl View for AwsBedrockCredentialsErrorView {
|
||||
|
||||
let make_detail_text = || {
|
||||
Text::new(
|
||||
format!(
|
||||
"Failed to authenticate with AWS Bedrock when using {}. \
|
||||
Run `{}` to refresh credentials.",
|
||||
self.model_name, self.login_command
|
||||
),
|
||||
if self.login_command.is_empty() {
|
||||
format!(
|
||||
"Failed to authenticate with AWS Bedrock when using {}. \
|
||||
Refresh credentials for the AWS profile selected in Providers, then retry.",
|
||||
self.model_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Failed to authenticate with AWS Bedrock when using {}. \
|
||||
Run `{}` to refresh credentials.",
|
||||
self.model_name, self.login_command
|
||||
)
|
||||
},
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
|
||||
@@ -877,7 +877,7 @@ impl RequestedCommandView {
|
||||
) if show_for_action_id == &self.action_id => {
|
||||
*shown.lock() = true;
|
||||
Some(render_autonomy_checkbox_setting_speedbump_footer(
|
||||
"Always allow Oz to execute read-only commands (relies on model)",
|
||||
"Always allow Galaxy to execute read-only commands (relies on model)",
|
||||
*checked,
|
||||
AIBlockAction::ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox,
|
||||
self.autoexecute_readonly_commands_speedbump_checkbox_handle
|
||||
|
||||
+356
-224
@@ -2,12 +2,17 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use futures::channel::oneshot;
|
||||
use futures::StreamExt;
|
||||
use galaxy_agent_rig::ChatGPTCredentialResolver;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use galaxyui_extras::secure_storage::AppContextExt as _;
|
||||
use instant::Instant;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -21,6 +26,10 @@ const CALLBACK_HOST: &str = "localhost";
|
||||
const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
|
||||
const CALLBACK_PATH: &str = "/auth/callback";
|
||||
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
|
||||
const AUTH_STORAGE_KEY: &str = "galaxy.chatgpt.oauth";
|
||||
const SIGN_IN_REQUIRED: &str = "ChatGPT sign-in required. Connect or reconnect ChatGPT in Providers.";
|
||||
type Reply = oneshot::Sender<Result<ChatGPTAuthCredentials, String>>;
|
||||
static RESOLVER: OnceLock<Mutex<Option<async_channel::Sender<Reply>>>> = OnceLock::new();
|
||||
|
||||
/// Current state of the local ChatGPT subscription connection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -45,26 +54,42 @@ struct OAuthCallback {
|
||||
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
|
||||
pub(crate) struct ChatGPTAuthModel {
|
||||
state: ChatGPTAuthState,
|
||||
record: Option<AuthRecord>,
|
||||
refresh: RefreshCoordinator,
|
||||
sign_in_generation: u64,
|
||||
sign_in_cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ChatGPTAuthModel {
|
||||
pub(crate) fn new() -> Self {
|
||||
let state = match has_or_import_auth_credentials() {
|
||||
Ok(()) => ChatGPTAuthState::Connected,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}"
|
||||
);
|
||||
ChatGPTAuthState::NotConnected
|
||||
}
|
||||
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let (tx, rx) = async_channel::bounded(64);
|
||||
*RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
|
||||
ctx.spawn_stream_local(rx, |model, reply, ctx| model.resolve(reply, ctx), |_, _| {});
|
||||
let record = ctx.secure_storage().read_value(AUTH_STORAGE_KEY).ok()
|
||||
.and_then(|json| serde_json::from_str::<AuthRecord>(&json).ok())
|
||||
.filter(|record| record.credentials(now()).is_ok() || record.can_refresh());
|
||||
let state = if record.is_some() {
|
||||
ChatGPTAuthState::Connected
|
||||
} else {
|
||||
ChatGPTAuthState::NotConnected
|
||||
};
|
||||
Self { state }
|
||||
Self {
|
||||
state,
|
||||
record,
|
||||
refresh: RefreshCoordinator::default(),
|
||||
sign_in_generation: 0,
|
||||
sign_in_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_for_test() -> Self {
|
||||
Self {
|
||||
state: ChatGPTAuthState::NotConnected,
|
||||
record: None,
|
||||
refresh: RefreshCoordinator::default(),
|
||||
sign_in_generation: 0,
|
||||
sign_in_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,22 +97,26 @@ impl ChatGPTAuthModel {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Reuses persisted credentials when possible, otherwise starts browser OAuth with PKCE.
|
||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
fn begin_sign_in(&mut self) -> Option<u64> {
|
||||
if matches!(
|
||||
self.state,
|
||||
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||
) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
if has_or_import_auth_credentials().is_ok() {
|
||||
self.state = ChatGPTAuthState::Connected;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
return;
|
||||
}
|
||||
self.refresh.invalidate();
|
||||
self.sign_in_generation += 1;
|
||||
Some(self.sign_in_generation)
|
||||
}
|
||||
|
||||
let (redirect_uri, callback_rx) = match start_oauth_callback_server() {
|
||||
/// Explicit connection always starts a fresh browser OAuth flow with PKCE.
|
||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(generation) = self.begin_sign_in() else {
|
||||
return;
|
||||
};
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let (redirect_uri, callback_rx) = match start_oauth_callback_server(cancelled.clone()) {
|
||||
Ok(server) => server,
|
||||
Err(error) => {
|
||||
self.fail(error, ctx);
|
||||
@@ -106,8 +135,8 @@ impl ChatGPTAuthModel {
|
||||
}
|
||||
};
|
||||
|
||||
self.state = ChatGPTAuthState::AwaitingBrowser;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
self.sign_in_cancel = Some(cancelled);
|
||||
self.set_state(ChatGPTAuthState::AwaitingBrowser, ctx);
|
||||
ctx.open_url(authorize_url.as_str());
|
||||
|
||||
let _ = ctx.spawn(
|
||||
@@ -117,6 +146,9 @@ impl ChatGPTAuthModel {
|
||||
})?
|
||||
},
|
||||
move |model, callback, ctx| {
|
||||
if model.sign_in_generation != generation {
|
||||
return;
|
||||
}
|
||||
let callback = match callback {
|
||||
Ok(callback) => callback,
|
||||
Err(error) => {
|
||||
@@ -129,19 +161,27 @@ impl ChatGPTAuthModel {
|
||||
return;
|
||||
}
|
||||
|
||||
model.state = ChatGPTAuthState::ExchangingToken;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
model.set_state(ChatGPTAuthState::ExchangingToken, ctx);
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri)
|
||||
.await
|
||||
},
|
||||
|model, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
model.state = ChatGPTAuthState::Connected;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
move |model, result, ctx| {
|
||||
if model.sign_in_generation != generation {
|
||||
return;
|
||||
}
|
||||
match result.and_then(|record| {
|
||||
save_auth_record(&record, ctx)?;
|
||||
Ok(record)
|
||||
}) {
|
||||
Ok(record) => {
|
||||
model.record = Some(record);
|
||||
model.sign_in_cancel = None;
|
||||
model.set_state(ChatGPTAuthState::Connected, ctx);
|
||||
}
|
||||
Err(error) => model.fail(error, ctx),
|
||||
}
|
||||
Err(error) => model.fail(error, ctx),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -149,8 +189,77 @@ impl ChatGPTAuthModel {
|
||||
}
|
||||
|
||||
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
|
||||
self.state = ChatGPTAuthState::Failed(error);
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
if let Some(cancelled) = self.sign_in_cancel.take() {
|
||||
cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
self.set_state(ChatGPTAuthState::Failed(error), ctx);
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: ChatGPTAuthState, ctx: &mut ModelContext<Self>) {
|
||||
if self.state != state {
|
||||
self.state = state;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_sign_in(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(cancelled) = self.sign_in_cancel.take() {
|
||||
cancelled.store(true, Ordering::SeqCst);
|
||||
self.sign_in_generation += 1;
|
||||
self.refresh.invalidate();
|
||||
self.set_state(ChatGPTAuthState::NotConnected, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&mut self, reply: Reply, ctx: &mut ModelContext<Self>) {
|
||||
if reply.is_canceled() {
|
||||
return;
|
||||
}
|
||||
if matches!(self.state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken) {
|
||||
let _ = reply.send(Err("ChatGPT sign-in is in progress.".to_owned()));
|
||||
return;
|
||||
}
|
||||
let Some(record) = self.record.as_ref() else {
|
||||
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
|
||||
return;
|
||||
};
|
||||
if let Ok(credentials) = record.credentials(now()) {
|
||||
let _ = reply.send(Ok(credentials));
|
||||
return;
|
||||
}
|
||||
if !record.can_refresh() {
|
||||
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
|
||||
self.fail(SIGN_IN_REQUIRED.to_owned(), ctx);
|
||||
return;
|
||||
}
|
||||
let record = record.clone();
|
||||
let Some(generation) = self.refresh.begin(reply) else {
|
||||
return;
|
||||
};
|
||||
let _ = ctx.spawn(
|
||||
async move { refresh_tokens(&record).await },
|
||||
move |model, result, ctx| {
|
||||
let completed = model.refresh.complete(generation, result, |record| {
|
||||
save_auth_record(record, ctx)
|
||||
});
|
||||
match completed {
|
||||
None => {}
|
||||
Some(Ok(record)) => {
|
||||
model.record = Some(record);
|
||||
model.set_state(ChatGPTAuthState::Connected, ctx);
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
if error.reauthenticate {
|
||||
model.record = None;
|
||||
if save_auth_record(&AuthRecord::default(), ctx).is_err() {
|
||||
log::warn!("[chatgpt/auth] Could not persist reconnect-required state");
|
||||
}
|
||||
}
|
||||
model.fail(error.message, ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +303,7 @@ fn random_hex_string(byte_count: usize) -> String {
|
||||
}
|
||||
|
||||
fn start_oauth_callback_server(
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> {
|
||||
let mut bind_errors = Vec::new();
|
||||
let mut bound = None;
|
||||
@@ -222,6 +332,10 @@ fn start_oauth_callback_server(
|
||||
.spawn(move || {
|
||||
let deadline = Instant::now() + CALLBACK_TIMEOUT;
|
||||
loop {
|
||||
if cancelled.load(Ordering::SeqCst) {
|
||||
let _ = callback_tx.send(Err("ChatGPT sign-in was cancelled.".to_owned()));
|
||||
return;
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let _ = callback_tx.send(handle_callback_connection(stream));
|
||||
@@ -323,26 +437,19 @@ fn parse_callback_request_line(request_line: &str) -> Result<OAuthCallback, Stri
|
||||
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
let mut oauth_error = None;
|
||||
let mut error_description = None;
|
||||
let mut oauth_error = false;
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" if !value.is_empty() => code = Some(value.into_owned()),
|
||||
"state" if !value.is_empty() => state = Some(value.into_owned()),
|
||||
"error" if !value.is_empty() => oauth_error = Some(value.into_owned()),
|
||||
"error_description" if !value.is_empty() => {
|
||||
error_description = Some(value.into_owned())
|
||||
}
|
||||
"error" if !value.is_empty() => oauth_error = true,
|
||||
"code" | "state" | "error" | "error_description" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = oauth_error {
|
||||
return Err(format!(
|
||||
"ChatGPT authorization failed: {error} ({})",
|
||||
error_description.as_deref().unwrap_or("no description")
|
||||
));
|
||||
if oauth_error {
|
||||
return Err("ChatGPT browser authorization failed. Please try signing in again.".to_owned());
|
||||
}
|
||||
Ok(OAuthCallback {
|
||||
code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?,
|
||||
@@ -358,157 +465,101 @@ fn callback_response_page(title: &str, message: &str, is_error: bool) -> String
|
||||
.replace("__ACCENT__", accent)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ChatGPTAuthCredentials {
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) account_id: Option<String>,
|
||||
}
|
||||
pub(crate) use galaxy_agent_rig::ChatGPTSubscriptionCredentials as ChatGPTAuthCredentials;
|
||||
|
||||
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file.
|
||||
/// Returns `Ok(())` if credentials were found and successfully imported.
|
||||
pub(crate) fn import_codex_credentials() -> Result<(), String> {
|
||||
let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?;
|
||||
let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?;
|
||||
|
||||
let tokens = doc.get("tokens").ok_or("No tokens object")?;
|
||||
let access_token = tokens
|
||||
.get("access_token")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|token| !token.is_empty());
|
||||
let refresh_token = tokens
|
||||
.get("refresh_token")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if access_token.is_none() && refresh_token.is_none() {
|
||||
return Err("Codex auth file does not contain an access or refresh token".to_string());
|
||||
}
|
||||
let expires_at = access_token.and_then(extract_expiration_timestamp);
|
||||
let id_token = tokens
|
||||
.get("id_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned);
|
||||
let account_id = tokens
|
||||
.get("account_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| extract_account_id(id_token.as_deref()))
|
||||
.or_else(|| extract_account_id(access_token));
|
||||
|
||||
let record = AuthRecord {
|
||||
access_token: access_token.map(ToOwned::to_owned),
|
||||
refresh_token,
|
||||
id_token,
|
||||
expires_at,
|
||||
account_id,
|
||||
};
|
||||
|
||||
write_auth_file(&record)
|
||||
pub(crate) fn credential_resolver() -> ChatGPTCredentialResolver {
|
||||
Arc::new(|| Box::pin(load_or_refresh_auth_credentials()))
|
||||
}
|
||||
|
||||
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
||||
if let Ok(credentials) = load_auth_credentials() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
let _ = import_codex_credentials();
|
||||
if let Ok(credentials) = load_auth_credentials() {
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
galaxy_agent_rig::refresh_chatgpt_subscription_credentials().await?;
|
||||
load_auth_credentials()
|
||||
}
|
||||
|
||||
fn has_or_import_auth_credentials() -> Result<(), String> {
|
||||
if has_auth_credentials() {
|
||||
return Ok(());
|
||||
}
|
||||
import_codex_credentials()?;
|
||||
if has_auth_credentials() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("ChatGPT credentials are missing an access or refresh token.".to_string())
|
||||
let (tx, rx) = oneshot::channel();
|
||||
RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap()
|
||||
.as_ref().ok_or("ChatGPT authorization service is unavailable.")?
|
||||
.try_send(tx).map_err(|_| "ChatGPT authorization service is busy or unavailable.")?;
|
||||
match futures::future::select(
|
||||
Box::pin(rx),
|
||||
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
|
||||
).await {
|
||||
futures::future::Either::Left((result, _)) => {
|
||||
result.map_err(|_| "ChatGPT authorization service stopped.".to_owned())?
|
||||
}
|
||||
futures::future::Either::Right(_) => {
|
||||
Err("ChatGPT credential refresh timed out. Retry or sign in again.".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_auth_credentials() -> bool {
|
||||
let Some(path) = auth_file_path() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(record) = serde_json::from_slice::<AuthRecord>(&bytes) else {
|
||||
return false;
|
||||
};
|
||||
record
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.is_some_and(|token| !token.trim().is_empty())
|
||||
|| record.access_token.as_deref().is_some_and(|token| {
|
||||
!token.trim().is_empty()
|
||||
&& !token_is_expired(
|
||||
record
|
||||
.expires_at
|
||||
.or_else(|| extract_expiration_timestamp(token)),
|
||||
)
|
||||
})
|
||||
#[derive(Clone, Debug)]
|
||||
struct AuthFailure {
|
||||
message: String,
|
||||
reauthenticate: bool,
|
||||
}
|
||||
|
||||
fn load_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
|
||||
let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?;
|
||||
let bytes = std::fs::read(&path)
|
||||
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
||||
let record: AuthRecord = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
|
||||
let access_token = record
|
||||
.access_token
|
||||
.as_deref()
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.ok_or("ChatGPT auth file does not contain an access token")?;
|
||||
#[derive(Default)]
|
||||
struct RefreshCoordinator {
|
||||
generation: u64,
|
||||
active: Option<(u64, Vec<Reply>)>,
|
||||
}
|
||||
|
||||
let expires_at = record
|
||||
.expires_at
|
||||
.or_else(|| extract_expiration_timestamp(access_token));
|
||||
if token_is_expired(expires_at) {
|
||||
return Err("ChatGPT access token is expired".to_string());
|
||||
impl RefreshCoordinator {
|
||||
fn begin(&mut self, reply: Reply) -> Option<u64> {
|
||||
if let Some((_, replies)) = &mut self.active {
|
||||
replies.push(reply);
|
||||
return None;
|
||||
}
|
||||
self.generation += 1;
|
||||
self.active = Some((self.generation, vec![reply]));
|
||||
Some(self.generation)
|
||||
}
|
||||
|
||||
let account_id = record
|
||||
.account_id
|
||||
.clone()
|
||||
.filter(|account_id| !account_id.trim().is_empty())
|
||||
.or_else(|| extract_account_id(record.id_token.as_deref()))
|
||||
.or_else(|| extract_account_id(Some(access_token)));
|
||||
fn invalidate(&mut self) {
|
||||
self.generation += 1;
|
||||
if let Some((_, replies)) = self.active.take() {
|
||||
for reply in replies {
|
||||
let _ = reply.send(Err("ChatGPT session was replaced. Please sign in again.".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChatGPTAuthCredentials {
|
||||
access_token: access_token.to_string(),
|
||||
account_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
||||
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
||||
fn complete(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
result: Result<AuthRecord, AuthFailure>,
|
||||
persist: impl FnOnce(&AuthRecord) -> Result<(), String>,
|
||||
) -> Option<Result<AuthRecord, AuthFailure>> {
|
||||
if self.active.as_ref().is_none_or(|(active, _)| *active != generation) {
|
||||
return None;
|
||||
}
|
||||
let (_, replies) = self.active.take()?;
|
||||
let result = result.and_then(|record| {
|
||||
let credentials = record.access_context().map_err(|message| AuthFailure {
|
||||
message, reauthenticate: true,
|
||||
})?;
|
||||
persist(&record).map_err(|_| AuthFailure {
|
||||
message: "Could not securely save refreshed ChatGPT credentials. Please sign in again.".to_owned(),
|
||||
reauthenticate: true,
|
||||
})?;
|
||||
Ok((record, credentials))
|
||||
});
|
||||
for reply in replies {
|
||||
let response = result.as_ref().map(|(_, credentials)| credentials.clone())
|
||||
.map_err(|error| error.message.clone());
|
||||
let _ = reply.send(response);
|
||||
}
|
||||
Some(result.map(|(record, _)| record))
|
||||
}
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
||||
}
|
||||
|
||||
fn token_is_expired(expires_at: Option<i64>) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
expires_at.is_none_or(|expires_at| now >= expires_at - 60)
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn exchange_code_for_tokens(
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<AuthRecord, String> {
|
||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("grant_type", "authorization_code")
|
||||
.append_pair("client_id", CHATGPT_CLIENT_ID)
|
||||
@@ -516,7 +567,34 @@ async fn exchange_code_for_tokens(
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("code_verifier", code_verifier)
|
||||
.finish();
|
||||
let response = reqwest::Client::new()
|
||||
let tokens = token_request(body).await.map_err(|error| error.message)?;
|
||||
record_from_tokens(tokens, None, now()).map_err(|error| error.message)
|
||||
}
|
||||
|
||||
async fn refresh_tokens(previous: &AuthRecord) -> Result<AuthRecord, AuthFailure> {
|
||||
let refresh_token = previous.refresh_token.as_deref().filter(|token| !token.trim().is_empty())
|
||||
.ok_or_else(|| AuthFailure {
|
||||
message: SIGN_IN_REQUIRED.to_owned(),
|
||||
reauthenticate: true,
|
||||
})?;
|
||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("grant_type", "refresh_token")
|
||||
.append_pair("client_id", CHATGPT_CLIENT_ID)
|
||||
.append_pair("refresh_token", refresh_token)
|
||||
.finish();
|
||||
let tokens = token_request(body).await?;
|
||||
record_from_tokens(tokens, Some(previous), now())
|
||||
}
|
||||
|
||||
async fn token_request(body: String) -> Result<TokenResponse, AuthFailure> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build().map_err(|_| AuthFailure {
|
||||
message: "Could not create the ChatGPT authorization client.".to_owned(),
|
||||
reauthenticate: false,
|
||||
})?;
|
||||
let response = client
|
||||
.post(CHATGPT_TOKEN_URL)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
@@ -525,67 +603,83 @@ async fn exchange_code_for_tokens(
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("ChatGPT token exchange request failed: {error}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("ChatGPT token exchange failed: {status} {body}"));
|
||||
.map_err(|_| AuthFailure {
|
||||
message: "ChatGPT authorization request failed. Retry the connection.".to_owned(),
|
||||
reauthenticate: false,
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|_| AuthFailure {
|
||||
message: "Could not read the ChatGPT authorization response.".to_owned(),
|
||||
reauthenticate: (200..300).contains(&status),
|
||||
})?;
|
||||
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
|
||||
return Err(AuthFailure {
|
||||
message: "ChatGPT authorization returned an oversized response.".to_owned(),
|
||||
reauthenticate: (200..300).contains(&status),
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let tokens: TokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?;
|
||||
parse_token_response(status, &bytes)
|
||||
}
|
||||
|
||||
fn parse_token_response(status: u16, bytes: &[u8]) -> Result<TokenResponse, AuthFailure> {
|
||||
if (200..300).contains(&status) {
|
||||
return serde_json::from_slice(bytes).map_err(|_| AuthFailure {
|
||||
message: "ChatGPT returned invalid credentials. Please sign in again.".to_owned(),
|
||||
reauthenticate: true,
|
||||
});
|
||||
}
|
||||
let body = serde_json::from_slice::<serde_json::Value>(bytes).unwrap_or_default();
|
||||
let code = body.get("error").and_then(|error| {
|
||||
error.as_str().or_else(|| error.get("code").and_then(|code| code.as_str()))
|
||||
});
|
||||
let reauthenticate = matches!(status, 401 | 403)
|
||||
|| (status == 400 && matches!(code,
|
||||
Some("invalid_grant" | "refresh_token_reused" | "refresh_token_expired" | "refresh_token_invalid")
|
||||
));
|
||||
let message = if reauthenticate {
|
||||
format!("ChatGPT authorization expired or was rejected (HTTP {status}). Please sign in again.")
|
||||
} else {
|
||||
format!("ChatGPT authorization failed (HTTP {status}). Retry the connection.")
|
||||
};
|
||||
Err(AuthFailure { message, reauthenticate })
|
||||
}
|
||||
|
||||
fn record_from_tokens(
|
||||
tokens: TokenResponse,
|
||||
previous: Option<&AuthRecord>,
|
||||
now: i64,
|
||||
) -> Result<AuthRecord, AuthFailure> {
|
||||
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
|
||||
tokens.expires_in.map(|expires_in| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
+ expires_in as i64
|
||||
})
|
||||
tokens.expires_in.and_then(|expires| i64::try_from(expires).ok())
|
||||
.map(|expires| now.saturating_add(expires))
|
||||
});
|
||||
let account_id = extract_account_id(tokens.id_token.as_deref())
|
||||
.or_else(|| extract_account_id(Some(&tokens.access_token)));
|
||||
write_auth_file(&AuthRecord {
|
||||
.or_else(|| extract_account_id(Some(&tokens.access_token)))
|
||||
.or_else(|| previous.and_then(|record| record.account_id.clone()));
|
||||
let record = AuthRecord {
|
||||
access_token: Some(tokens.access_token),
|
||||
refresh_token: tokens.refresh_token,
|
||||
id_token: tokens.id_token,
|
||||
refresh_token: tokens.refresh_token.or_else(|| previous.and_then(|record| record.refresh_token.clone())),
|
||||
id_token: tokens.id_token.or_else(|| previous.and_then(|record| record.id_token.clone())),
|
||||
expires_at,
|
||||
account_id,
|
||||
})
|
||||
};
|
||||
record.credentials(now).map_err(|_| AuthFailure {
|
||||
message: "ChatGPT returned invalid or expired credentials. Please sign in again.".to_owned(),
|
||||
reauthenticate: true,
|
||||
})?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
||||
let path = auth_file_path().ok_or("Cannot determine auth file path")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create auth directory: {e}"))?;
|
||||
}
|
||||
let json =
|
||||
serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_file_path() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var_os("APPDATA").map(|d| {
|
||||
std::path::PathBuf::from(d)
|
||||
.join("chatgpt")
|
||||
.join("auth.json")
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
|
||||
})
|
||||
.map(|d| d.join("chatgpt").join("auth.json"))
|
||||
}
|
||||
fn save_auth_record(record: &AuthRecord, ctx: &galaxyui::AppContext) -> Result<(), String> {
|
||||
let json = serde_json::to_string(record)
|
||||
.map_err(|_| "Could not encode ChatGPT credentials.".to_owned())?;
|
||||
ctx.secure_storage().write_value_with_owner_only_fallback(AUTH_STORAGE_KEY, &json)
|
||||
.map_err(|_| "Could not securely save ChatGPT credentials. Please sign in again.".to_owned())
|
||||
}
|
||||
|
||||
fn extract_expiration_timestamp(token: &str) -> Option<i64> {
|
||||
@@ -633,7 +727,7 @@ struct TokenResponse {
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[derive(Clone, Default, serde::Deserialize, serde::Serialize)]
|
||||
struct AuthRecord {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
@@ -641,3 +735,41 @@ struct AuthRecord {
|
||||
expires_at: Option<i64>,
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AuthRecord {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChatGPTAuthRecord")
|
||||
.field("credentials", &"<redacted>")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthRecord {
|
||||
fn can_refresh(&self) -> bool {
|
||||
self.refresh_token.as_deref().is_some_and(|token| !token.trim().is_empty())
|
||||
}
|
||||
|
||||
fn access_context(&self) -> Result<ChatGPTAuthCredentials, String> {
|
||||
let access_token = self.access_token.as_deref().filter(|token| !token.trim().is_empty())
|
||||
.ok_or_else(|| SIGN_IN_REQUIRED.to_owned())?;
|
||||
let account_id = self.account_id.clone().filter(|id| !id.trim().is_empty())
|
||||
.or_else(|| extract_account_id(self.id_token.as_deref()))
|
||||
.or_else(|| extract_account_id(Some(access_token)));
|
||||
Ok(ChatGPTAuthCredentials { access_token: access_token.to_owned(), account_id })
|
||||
}
|
||||
|
||||
fn credentials(&self, now: i64) -> Result<ChatGPTAuthCredentials, String> {
|
||||
let expiry = self.expires_at.or_else(|| {
|
||||
self.access_token.as_deref().and_then(extract_expiration_timestamp)
|
||||
});
|
||||
if expiry.is_none_or(|expiry| now >= expiry.saturating_sub(60)) {
|
||||
return Err(SIGN_IN_REQUIRED.to_owned());
|
||||
}
|
||||
self.access_context()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chatgpt_auth_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
use std::cell::Cell;
|
||||
|
||||
use futures::FutureExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn record(access: &str, refresh: &str) -> AuthRecord {
|
||||
AuthRecord {
|
||||
access_token: Some(access.to_owned()),
|
||||
refresh_token: Some(refresh.to_owned()),
|
||||
id_token: None,
|
||||
expires_at: Some(10_000),
|
||||
account_id: Some("test-account".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_requests_share_one_refresh_and_wait_for_persistence() {
|
||||
let mut coordinator = RefreshCoordinator::default();
|
||||
let (first_tx, mut first_rx) = oneshot::channel();
|
||||
let (second_tx, mut second_rx) = oneshot::channel();
|
||||
let generation = coordinator.begin(first_tx).unwrap();
|
||||
assert_eq!(coordinator.begin(second_tx), None);
|
||||
let saved = Cell::new(false);
|
||||
let result = coordinator.complete(generation, Ok(record("rotated-access", "rotated-refresh")), |record| {
|
||||
assert_eq!(first_rx.try_recv().unwrap(), None);
|
||||
assert_eq!(second_rx.try_recv().unwrap(), None);
|
||||
assert_eq!(record.refresh_token.as_deref(), Some("rotated-refresh"));
|
||||
saved.set(true);
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.unwrap().is_ok());
|
||||
assert!(saved.get());
|
||||
let first = first_rx.now_or_never().unwrap().unwrap().unwrap();
|
||||
let second = second_rx.now_or_never().unwrap().unwrap().unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.access_token, "rotated-access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_persistence_never_publishes_rotated_credentials() {
|
||||
let mut coordinator = RefreshCoordinator::default();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let generation = coordinator.begin(tx).unwrap();
|
||||
let result = coordinator.complete(generation, Ok(record("rotated", "new-refresh")), |_| {
|
||||
Err("storage unavailable".to_owned())
|
||||
}).unwrap().unwrap_err();
|
||||
assert!(result.reauthenticate);
|
||||
assert!(rx.now_or_never().unwrap().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_sign_in_rejects_late_refresh_without_overwriting_new_session() {
|
||||
let mut coordinator = RefreshCoordinator::default();
|
||||
let (old_tx, old_rx) = oneshot::channel();
|
||||
let old = coordinator.begin(old_tx).unwrap();
|
||||
coordinator.invalidate();
|
||||
assert!(old_rx.now_or_never().unwrap().unwrap().is_err());
|
||||
let (new_tx, new_rx) = oneshot::channel();
|
||||
let current = coordinator.begin(new_tx).unwrap();
|
||||
assert_ne!(old, current);
|
||||
assert!(coordinator.complete(old, Ok(record("stale", "stale-refresh")), |_| {
|
||||
panic!("A stale refresh must not write credentials")
|
||||
}).is_none());
|
||||
assert!(coordinator.complete(current, Ok(record("current", "current-refresh")), |_| Ok(()))
|
||||
.unwrap().is_ok());
|
||||
assert_eq!(new_rx.now_or_never().unwrap().unwrap().unwrap().access_token, "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_starts_a_new_generation_even_with_a_cached_refresh_token() {
|
||||
let mut model = ChatGPTAuthModel::new_for_test();
|
||||
model.record = Some(record("old-access", "already-used-refresh"));
|
||||
model.state = ChatGPTAuthState::Connected;
|
||||
let first = model.begin_sign_in().unwrap();
|
||||
model.state = ChatGPTAuthState::AwaitingBrowser;
|
||||
assert_eq!(model.begin_sign_in(), None);
|
||||
model.state = ChatGPTAuthState::Failed("refresh_token_reused".to_owned());
|
||||
let replacement = model.begin_sign_in().unwrap();
|
||||
assert!(replacement > first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_tokens_replace_previous_tokens_and_omitted_refresh_tokens_are_preserved() {
|
||||
let previous = record("old-access", "old-refresh");
|
||||
for refresh in [None, Some("new-refresh".to_owned())] {
|
||||
let result = record_from_tokens(TokenResponse {
|
||||
access_token: "new-access".to_owned(),
|
||||
refresh_token: refresh.clone(),
|
||||
id_token: None,
|
||||
expires_in: Some(3600),
|
||||
}, Some(&previous), 100).unwrap();
|
||||
assert_eq!(result.access_token.as_deref(), Some("new-access"));
|
||||
assert_eq!(result.refresh_token, refresh.or_else(|| Some("old-refresh".to_owned())));
|
||||
assert_eq!(result.expires_at, Some(3700));
|
||||
assert_eq!(result.account_id.as_deref(), Some("test-account"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_refresh_tokens_require_reauthentication_without_echoing_tokens() {
|
||||
for code in ["refresh_token_reused", "invalid_grant", "refresh_token_expired"] {
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"error": {"code": code, "message": "sensitive-refresh-token"}
|
||||
})).unwrap();
|
||||
let error = parse_token_response(401, &body).err().unwrap();
|
||||
assert!(error.reauthenticate);
|
||||
assert!(error.message.contains("sign in"));
|
||||
assert!(!error.message.contains("sensitive-refresh-token"));
|
||||
}
|
||||
let error = parse_token_response(400, br#"{"error":"invalid_grant"}"#).err().unwrap();
|
||||
assert!(error.reauthenticate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_refresh_failure_preserves_the_session_and_does_not_save_rejected_data() {
|
||||
let error = parse_token_response(503, br#"{"message":"sensitive-data"}"#).err().unwrap();
|
||||
assert!(!error.reauthenticate);
|
||||
assert!(!error.message.contains("sensitive-data"));
|
||||
let mut coordinator = RefreshCoordinator::default();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let generation = coordinator.begin(tx).unwrap();
|
||||
let result = coordinator.complete(generation, Err(error), |_| {
|
||||
panic!("Transient failures must not overwrite stored credentials")
|
||||
}).unwrap().unwrap_err();
|
||||
assert!(!result.reauthenticate);
|
||||
assert!(rx.now_or_never().unwrap().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_debug_output_is_redacted_and_expiry_has_a_refresh_margin() {
|
||||
let credentials = ChatGPTAuthCredentials {
|
||||
access_token: "sensitive-access-token".to_owned(),
|
||||
account_id: Some("account".to_owned()),
|
||||
};
|
||||
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
|
||||
let credentials = record("sensitive-access-token", "sensitive-refresh-token");
|
||||
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
|
||||
assert!(!format!("{credentials:?}").contains("sensitive-refresh-token"));
|
||||
assert!(credentials.credentials(100).is_ok());
|
||||
assert!(credentials.credentials(9940).is_err());
|
||||
assert!(AuthRecord::default().credentials(100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_payloads_are_not_saved_or_exposed() {
|
||||
assert!(parse_token_response(200, b"invalid-sensitive-data").is_err());
|
||||
assert!(record_from_tokens(TokenResponse {
|
||||
access_token: String::new(),
|
||||
refresh_token: Some("refresh".to_owned()),
|
||||
id_token: None,
|
||||
expires_in: Some(3600),
|
||||
}, None, 100).is_err());
|
||||
assert!(record_from_tokens(TokenResponse {
|
||||
access_token: "opaque-token-without-expiry".to_owned(),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: None,
|
||||
}, None, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_authorization_keeps_pkce_and_state_validation() {
|
||||
let url = build_authorize_url("http://localhost:1455/auth/callback", "challenge", "state").unwrap();
|
||||
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
|
||||
assert_eq!(params.get("code_challenge_method").map(|v| v.as_ref()), Some("S256"));
|
||||
assert_eq!(params.get("state").map(|v| v.as_ref()), Some("state"));
|
||||
let callback = parse_callback_request_line("GET /auth/callback?code=code&state=state HTTP/1.1").unwrap();
|
||||
assert_eq!(callback.state, "state");
|
||||
assert!(parse_callback_request_line("GET /auth/callback?code=code HTTP/1.1").is_err());
|
||||
}
|
||||
@@ -246,9 +246,20 @@ pub struct ConversationDetailsData {
|
||||
harness: Option<Harness>,
|
||||
/// Error details displayed when the API call to fetch run data failed.
|
||||
fetch_error: Option<TaskFetchError>,
|
||||
/// Local history resumes the saved session instead of offering a hosted fork.
|
||||
local_history: bool,
|
||||
}
|
||||
|
||||
impl ConversationDetailsData {
|
||||
pub fn for_local_history(mut self) -> Self {
|
||||
self.local_history = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_local_history(&self) -> bool {
|
||||
self.local_history
|
||||
}
|
||||
|
||||
fn directory_for_task(task: &AmbientAgentTask, app: &AppContext) -> Option<String> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let conversation_id = history_model
|
||||
@@ -358,6 +369,7 @@ impl ConversationDetailsData {
|
||||
skill_spec: None,
|
||||
harness,
|
||||
fetch_error: None,
|
||||
local_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +434,7 @@ impl ConversationDetailsData {
|
||||
skill_spec,
|
||||
harness,
|
||||
fetch_error: None,
|
||||
local_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +507,7 @@ impl ConversationDetailsData {
|
||||
skill_spec,
|
||||
harness,
|
||||
fetch_error: None,
|
||||
local_history: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -521,6 +535,7 @@ impl ConversationDetailsData {
|
||||
skill_spec: None,
|
||||
harness,
|
||||
fetch_error: None,
|
||||
local_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +567,7 @@ impl ConversationDetailsData {
|
||||
skill_spec: None,
|
||||
harness: None,
|
||||
fetch_error,
|
||||
local_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +610,7 @@ impl ConversationDetailsData {
|
||||
skill_spec: None,
|
||||
harness,
|
||||
fetch_error: None,
|
||||
local_history: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -752,6 +769,10 @@ impl ConversationDetailsPanel {
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<DetailsPanelLocalContinuationInfo> {
|
||||
if self.data.is_local_history() {
|
||||
// History's Open action restores the original local session directly.
|
||||
return None;
|
||||
}
|
||||
if !AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
return None;
|
||||
}
|
||||
@@ -894,6 +915,9 @@ impl ConversationDetailsPanel {
|
||||
open_action,
|
||||
data.copy_link_url.clone(),
|
||||
);
|
||||
if data.is_local_history() {
|
||||
config.fork_conversation_id = None;
|
||||
}
|
||||
if !status.as_ref().is_some_and(ConversationStatus::is_done) {
|
||||
config.delete_conversation_id = None;
|
||||
}
|
||||
|
||||
@@ -181,28 +181,14 @@ impl CrosscheckReviewer {
|
||||
|
||||
// Fall back to Bedrock via external config
|
||||
if *settings.bedrock_enabled.value() {
|
||||
let auth_method = *settings.bedrock_auth_method.value();
|
||||
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
|
||||
let mut config = crate::ai::provider::client::BedrockClientConfig {
|
||||
auth_method,
|
||||
profile: settings.bedrock_profile.value().clone(),
|
||||
region: settings.bedrock_region.value().clone(),
|
||||
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
||||
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
||||
session_token: None,
|
||||
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
|
||||
};
|
||||
|
||||
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
|
||||
api_key_manager.aws_credentials_state()
|
||||
{
|
||||
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
|
||||
config.access_key_id = credentials.access_key().to_string();
|
||||
config.secret_access_key = credentials.secret_key().to_string();
|
||||
config.session_token = credentials.session_token().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
return ProviderConfig::Bedrock(config.with_external_fallbacks());
|
||||
let config = crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
|
||||
.with_model_route(model_id, settings.bedrock_models.value())
|
||||
.with_managed_credentials(
|
||||
&api_key_manager.aws_credentials_refresh_strategy(),
|
||||
api_key_manager.aws_credentials_state(),
|
||||
);
|
||||
return ProviderConfig::Bedrock(config);
|
||||
}
|
||||
|
||||
ProviderConfig::None
|
||||
@@ -229,9 +215,8 @@ impl CrosscheckReviewer {
|
||||
model: config.model.unwrap_or(model_id),
|
||||
reasoning_effort: config.reasoning_effort,
|
||||
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
|
||||
auth_file: None,
|
||||
},
|
||||
);
|
||||
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
|
||||
runtime
|
||||
.complete_text(
|
||||
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
|
||||
|
||||
@@ -687,7 +687,7 @@ impl CustomRouterEditorView {
|
||||
);
|
||||
let rules_copy = FormattedText::new([
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(
|
||||
"Rules are custom prompts that describe when to use a specific model. Warp intelligently matches your tasks against these rules.",
|
||||
"Rules are custom prompts that describe when to use a specific model. Galaxy matches your tasks against these rules.",
|
||||
)]),
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(
|
||||
"Rules are matched top to bottom — rules higher in the list take precedence over those below.",
|
||||
@@ -750,7 +750,7 @@ impl CustomRouterEditorView {
|
||||
FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::bold("Complexity-based"),
|
||||
FormattedTextFragment::plain_text(
|
||||
" routing chooses a model based on Warp's classification of the task's difficulty.",
|
||||
" routing chooses a model based on Galaxy's classification of the task's difficulty.",
|
||||
),
|
||||
]),
|
||||
FormattedTextLine::Line(vec![
|
||||
|
||||
@@ -618,12 +618,12 @@ impl AIDocumentModel {
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!("Plan document {ai_document_id} was not found in Warp Drive.")
|
||||
format!("Plan document {ai_document_id} was not found in Galaxy Drive.")
|
||||
})?;
|
||||
let (sync_id, title, content) = notebook;
|
||||
if sync_id.into_server().is_none() {
|
||||
return Err(format!(
|
||||
"Plan document {ai_document_id} is not backed by a saved Warp Drive notebook."
|
||||
"Plan document {ai_document_id} is not backed by a saved Galaxy Drive notebook."
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod ai_document_model;
|
||||
pub mod orchestration_config_block;
|
||||
pub(crate) mod plan_archival;
|
||||
pub(in crate::ai) mod plan_publication;
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
//! Retain completed plans in a recoverable Drive folder after one week.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use galaxy_core::user_preferences::GetUserPreferences as _;
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ai_document_model::{
|
||||
AIDocumentId, AIDocumentModel, AIDocumentModelEvent, AIDocumentUpdateSource,
|
||||
AIDocumentUserEditStatus,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::{CloudObject, CloudObjectLocation, Space};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::local_object_repository::{
|
||||
local_owner, LocalObjectRepository, LocalObjectRepositoryEvent,
|
||||
};
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
|
||||
const PLAN_ARCHIVAL_KEY: &str = "CompletedPlanArchival";
|
||||
const PLAN_FOLDER_NAME: &str = "Plans";
|
||||
const ARCHIVE_FOLDER_NAME: &str = "Archive";
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const ARCHIVE_AFTER_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct CompletedPlan {
|
||||
conversation_id: AIConversationId,
|
||||
completed_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
archived_to: Option<SyncId>,
|
||||
#[serde(default)]
|
||||
original_folder: Option<SyncId>,
|
||||
}
|
||||
|
||||
impl CompletedPlan {
|
||||
fn is_due(&self, now: DateTime<Utc>) -> bool {
|
||||
self.archived_to.is_none()
|
||||
&& now.signed_duration_since(self.completed_at)
|
||||
>= chrono::Duration::days(ARCHIVE_AFTER_DAYS)
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion records are local preferences; notebook contents and folder
|
||||
/// membership continue to use the normal SQLite-backed Drive repository.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PlanArchivalModel {
|
||||
#[serde(default)]
|
||||
completed_plans: HashMap<String, CompletedPlan>,
|
||||
#[serde(default)]
|
||||
reopened_plans: HashMap<String, ReopenedPlan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct ReopenedPlan {
|
||||
conversation_id: AIConversationId,
|
||||
/// The edited plan must reach a new agent exchange before the old task
|
||||
/// list can be used as evidence of completion again.
|
||||
saw_new_exchange: bool,
|
||||
}
|
||||
|
||||
impl PlanArchivalModel {
|
||||
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let model = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(PLAN_ARCHIVAL_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|json| Self::from_persisted(&json))
|
||||
.unwrap_or_default();
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|model, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::AppendedExchange { conversation_id, .. } => {
|
||||
// Clear completion before a successor turn can start. A
|
||||
// previously archived plan becomes visible again as well.
|
||||
model.begin_exchange(*conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
|
||||
| BlocklistAIHistoryEvent::RestoredConversations { .. } => {
|
||||
model.sweep(ctx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&AIDocumentModel::handle(ctx), |model, _, event, ctx| {
|
||||
match event {
|
||||
AIDocumentModelEvent::DocumentUpdated {
|
||||
document_id,
|
||||
source: AIDocumentUpdateSource::User,
|
||||
..
|
||||
}
|
||||
| AIDocumentModelEvent::DocumentUserEditStatusUpdated {
|
||||
document_id,
|
||||
status: AIDocumentUserEditStatus::Dirty,
|
||||
} => model.reopen_document(*document_id, ctx),
|
||||
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
|
||||
document_id,
|
||||
status: AIDocumentUserEditStatus::UpToDate,
|
||||
} => model.accept_document_edits(*document_id, ctx),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), |model, _, event, ctx| {
|
||||
if matches!(event, CloudModelEvent::InitialLoadCompleted) {
|
||||
model.sweep(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&LocalObjectRepository::handle(ctx), |model, _, event, ctx| {
|
||||
let LocalObjectRepositoryEvent::NotebookContentEdited { id } = event else {
|
||||
return;
|
||||
};
|
||||
let document_id = CloudModel::as_ref(ctx)
|
||||
.get_notebook(id)
|
||||
.and_then(|notebook| notebook.model().ai_document_id);
|
||||
if let Some(document_id) = document_id {
|
||||
model.reopen_document(document_id, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// The first callback runs after singleton registration and session
|
||||
// restoration. Periodic checks also catch provider cleanup occurring
|
||||
// just after a successful status event, without delaying that cleanup.
|
||||
ctx.spawn(async {}, |model, _, ctx| {
|
||||
model.sweep(ctx);
|
||||
model.schedule_sweep(ctx);
|
||||
});
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
fn from_persisted(json: &str) -> Option<Self> {
|
||||
serde_json::from_str(json).ok().or_else(|| {
|
||||
// Preserve completion records written before reopened-plan state
|
||||
// was added to the preference payload.
|
||||
serde_json::from_str(json).ok().map(|completed_plans| Self {
|
||||
completed_plans,
|
||||
..Self::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn schedule_sweep(&self, ctx: &mut ModelContext<Self>) {
|
||||
ctx.spawn(
|
||||
async { Timer::after(SWEEP_INTERVAL).await },
|
||||
|model, _, ctx| {
|
||||
model.sweep(ctx);
|
||||
model.schedule_sweep(ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn sweep(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let before = self.completed_plans.clone();
|
||||
let reopened_before = self.reopened_plans.clone();
|
||||
self.reopened_plans.retain(|document_id, record| {
|
||||
!record.saw_new_exchange
|
||||
|| AIDocumentId::try_from(document_id.as_str())
|
||||
.ok()
|
||||
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id))
|
||||
.is_none_or(|document| document.user_edit_status.is_dirty())
|
||||
});
|
||||
let observations = {
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let documents = AIDocumentModel::as_ref(ctx);
|
||||
let mut seen = HashSet::new();
|
||||
history
|
||||
.all_live_conversations()
|
||||
.into_iter()
|
||||
.chain(history.all_cleared_conversations())
|
||||
.filter(|(_, conversation)| seen.insert(conversation.id()))
|
||||
.map(|(_, conversation)| {
|
||||
let mut document_ids = documents
|
||||
.get_all_documents_for_conversation(conversation.id())
|
||||
.into_iter()
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect::<HashSet<_>>();
|
||||
document_ids.extend(conversation.artifacts().iter().filter_map(
|
||||
|artifact| {
|
||||
if let Artifact::Plan { document_uid, .. } = artifact {
|
||||
Some(document_uid.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
));
|
||||
(
|
||||
conversation.id(),
|
||||
document_ids,
|
||||
completed_work_time(conversation),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for (conversation_id, document_ids, completed_at) in observations {
|
||||
match completed_at {
|
||||
Some(completed_at) => {
|
||||
for document_id in document_ids {
|
||||
self.observe_completion(document_id, conversation_id, completed_at);
|
||||
}
|
||||
}
|
||||
None => self.resume_conversation(conversation_id, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted completion records also cover unloaded conversations.
|
||||
// AppendedExchange clears them before later work starts. Do not use
|
||||
// metadata.last_modified_at as work identity: checkpoint and title
|
||||
// saves update that timestamp after the exchange has already finished.
|
||||
|
||||
let now = Utc::now();
|
||||
let due = self
|
||||
.completed_plans
|
||||
.iter()
|
||||
.filter(|(_, record)| record.is_due(now))
|
||||
.map(|(document_id, _)| document_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for document_id in due {
|
||||
self.archive_plan(&document_id, ctx);
|
||||
}
|
||||
|
||||
if before != self.completed_plans || reopened_before != self.reopened_plans {
|
||||
self.persist(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_completion(
|
||||
&mut self,
|
||||
document_id: String,
|
||||
conversation_id: AIConversationId,
|
||||
completed_at: DateTime<Utc>,
|
||||
) {
|
||||
if self.reopened_plans.contains_key(&document_id) {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.completed_plans
|
||||
.get(&document_id)
|
||||
.is_some_and(|record| record.conversation_id == conversation_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.completed_plans.insert(
|
||||
document_id,
|
||||
CompletedPlan {
|
||||
conversation_id,
|
||||
completed_at,
|
||||
archived_to: None,
|
||||
original_folder: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn archive_plan(&mut self, document_id: &str, ctx: &mut ModelContext<Self>) {
|
||||
if self.reopened_plans.contains_key(document_id) {
|
||||
return;
|
||||
}
|
||||
if let Some(id) = AIDocumentId::try_from(document_id).ok().filter(|id| {
|
||||
AIDocumentModel::as_ref(ctx)
|
||||
.get_current_document(id)
|
||||
.is_some_and(|document| document.user_edit_status.is_dirty())
|
||||
}) {
|
||||
self.reopen_document(id, ctx);
|
||||
return;
|
||||
}
|
||||
let notebook = CloudModel::as_ref(ctx)
|
||||
.get_all_active_notebooks()
|
||||
.find(|notebook| {
|
||||
notebook.permissions.owner == local_owner()
|
||||
&& notebook
|
||||
.model()
|
||||
.ai_document_id
|
||||
.is_some_and(|id| id.to_string() == document_id)
|
||||
})
|
||||
.cloned();
|
||||
let Some(notebook) = notebook else {
|
||||
return;
|
||||
};
|
||||
|
||||
let plans_folder = get_or_create_folder(PLAN_FOLDER_NAME, None, ctx);
|
||||
let archive_folder = get_or_create_folder(ARCHIVE_FOLDER_NAME, Some(plans_folder), ctx);
|
||||
let original_folder = notebook.metadata.folder_id;
|
||||
if original_folder != Some(archive_folder)
|
||||
&& !LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(notebook.id),
|
||||
CloudObjectLocation::Folder(archive_folder),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(record) = self.completed_plans.get_mut(document_id) {
|
||||
record.original_folder = original_folder.filter(|id| *id != archive_folder);
|
||||
record.archived_to = Some(archive_folder);
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let document_ids = self
|
||||
.completed_plans
|
||||
.iter()
|
||||
.filter(|(_, record)| record.conversation_id == conversation_id)
|
||||
.map(|(document_id, _)| document_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if document_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
for document_id in document_ids {
|
||||
let Some(record) = self.completed_plans.remove(&document_id) else {
|
||||
continue;
|
||||
};
|
||||
restore_archived_plan(&document_id, record, ctx);
|
||||
}
|
||||
self.persist(ctx);
|
||||
}
|
||||
|
||||
fn begin_exchange(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.resume_conversation(conversation_id, ctx);
|
||||
let mut changed = false;
|
||||
self.reopened_plans.retain(|document_id, record| {
|
||||
if record.conversation_id != conversation_id {
|
||||
return true;
|
||||
}
|
||||
record.saw_new_exchange = true;
|
||||
changed = true;
|
||||
let document = AIDocumentId::try_from(document_id.as_str())
|
||||
.ok()
|
||||
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id));
|
||||
document.is_none_or(|document| document.user_edit_status.is_dirty())
|
||||
});
|
||||
if changed {
|
||||
self.persist(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn reopen_document(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
|
||||
let key = document_id.to_string();
|
||||
let completed = self.completed_plans.remove(&key);
|
||||
// Restored free-floating document views can have a synthetic owner.
|
||||
// Prefer the real owner retained in the completion/reopening record.
|
||||
let conversation_id = completed
|
||||
.as_ref()
|
||||
.map(|record| record.conversation_id)
|
||||
.or_else(|| {
|
||||
self.reopened_plans
|
||||
.get(&key)
|
||||
.map(|record| record.conversation_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&document_id)
|
||||
});
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
self.reopened_plans.insert(
|
||||
key.clone(),
|
||||
ReopenedPlan {
|
||||
conversation_id,
|
||||
saw_new_exchange: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(record) = completed {
|
||||
restore_archived_plan(&key, record, ctx);
|
||||
}
|
||||
self.persist(ctx);
|
||||
}
|
||||
|
||||
fn accept_document_edits(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
|
||||
let key = document_id.to_string();
|
||||
if self
|
||||
.reopened_plans
|
||||
.get(&key)
|
||||
.is_some_and(|record| record.saw_new_exchange)
|
||||
{
|
||||
self.reopened_plans.remove(&key);
|
||||
self.persist(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(&self, ctx: &mut ModelContext<Self>) {
|
||||
match serde_json::to_string(self) {
|
||||
Ok(json) => {
|
||||
if let Err(error) = ctx
|
||||
.private_user_preferences()
|
||||
.write_value(PLAN_ARCHIVAL_KEY, json)
|
||||
{
|
||||
log::error!("Failed to persist completed plan retention: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::error!("Failed to serialize completed plan retention: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_archived_plan(
|
||||
document_id: &str,
|
||||
record: CompletedPlan,
|
||||
ctx: &mut ModelContext<PlanArchivalModel>,
|
||||
) {
|
||||
let Some(archive_folder) = record.archived_to else {
|
||||
return;
|
||||
};
|
||||
let notebook = CloudModel::as_ref(ctx)
|
||||
.get_all_active_notebooks()
|
||||
.find(|notebook| {
|
||||
notebook.permissions.owner == local_owner()
|
||||
&& notebook.metadata.folder_id == Some(archive_folder)
|
||||
&& notebook
|
||||
.model()
|
||||
.ai_document_id
|
||||
.is_some_and(|id| id.to_string() == document_id)
|
||||
})
|
||||
.cloned();
|
||||
let Some(notebook) = notebook else {
|
||||
return;
|
||||
};
|
||||
let original_folder = record.original_folder.filter(|id| {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
cloud_model.get_folder(id).is_some_and(|folder| {
|
||||
folder.permissions.owner == local_owner() && !folder.is_trashed(cloud_model)
|
||||
})
|
||||
});
|
||||
let destination = original_folder
|
||||
.map(CloudObjectLocation::Folder)
|
||||
.unwrap_or(CloudObjectLocation::Space(Space::Personal));
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(notebook.id),
|
||||
destination,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn completed_work_time(conversation: &AIConversation) -> Option<DateTime<Utc>> {
|
||||
if !matches!(conversation.status(), ConversationStatus::Success)
|
||||
|| conversation.active_provider_run_json().is_some()
|
||||
|| conversation.has_active_subagent()
|
||||
|| !conversation
|
||||
.active_todo_list()
|
||||
.is_some_and(|list| list.is_finished())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
conversation
|
||||
.latest_exchange()
|
||||
.and_then(|exchange| exchange.finish_time)
|
||||
.map(|time| time.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn get_or_create_folder(
|
||||
name: &str,
|
||||
parent: Option<SyncId>,
|
||||
ctx: &mut ModelContext<PlanArchivalModel>,
|
||||
) -> SyncId {
|
||||
if let Some(id) = CloudModel::as_ref(ctx)
|
||||
.get_all_active_folders()
|
||||
.find(|folder| {
|
||||
folder.permissions.owner == local_owner()
|
||||
&& folder.model().name == name
|
||||
&& folder.metadata.folder_id == parent
|
||||
})
|
||||
.map(|folder| folder.id)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
let id = SyncId::ClientId(ClientId::new());
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_folder_with_id(id, name.to_string(), parent, ctx);
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
impl Entity for PlanArchivalModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for PlanArchivalModel {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plan_archival_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,417 @@
|
||||
use std::sync::mpsc::sync_channel;
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use galaxyui::App;
|
||||
|
||||
use super::*;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
fn completed_plan(completed_at: DateTime<Utc>) -> CompletedPlan {
|
||||
CompletedPlan {
|
||||
conversation_id: AIConversationId::new(),
|
||||
completed_at,
|
||||
archived_to: None,
|
||||
original_folder: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_plan_archives_after_seven_full_days_only() {
|
||||
let now = Utc::now();
|
||||
let plan = completed_plan(now - chrono::Duration::days(7));
|
||||
assert!(!plan.is_due(now - chrono::Duration::milliseconds(1)));
|
||||
assert!(plan.is_due(now));
|
||||
assert!(!completed_plan(now + chrono::Duration::days(1)).is_due(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_plan_deadline_and_recovery_location_survive_restart() {
|
||||
let mut plan = completed_plan(Utc::now() - chrono::Duration::days(8));
|
||||
plan.archived_to = Some(SyncId::ClientId(ClientId::new()));
|
||||
plan.original_folder = Some(SyncId::ClientId(ClientId::new()));
|
||||
let serialized = serde_json::to_string(&plan).unwrap();
|
||||
let restored: CompletedPlan = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(restored, plan);
|
||||
assert!(!restored.is_due(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revisiting_a_completed_plan_preserves_its_original_deadline() {
|
||||
let mut model = PlanArchivalModel {
|
||||
completed_plans: HashMap::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let conversation_id = AIConversationId::new();
|
||||
let completed_at = Utc::now() - chrono::Duration::days(6);
|
||||
model.observe_completion("plan".to_string(), conversation_id, completed_at);
|
||||
model.observe_completion("plan".to_string(), conversation_id, Utc::now());
|
||||
assert_eq!(model.completed_plans["plan"].completed_at, completed_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_completed_plan_records_are_preserved() {
|
||||
let record = completed_plan(Utc::now() - chrono::Duration::days(4));
|
||||
let records = HashMap::from([("plan".to_string(), record.clone())]);
|
||||
let json = serde_json::to_string(&records).unwrap();
|
||||
let model = PlanArchivalModel::from_persisted(&json).unwrap();
|
||||
assert_eq!(model.completed_plans["plan"], record);
|
||||
assert!(model.reopened_plans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_and_reverted_plans_stay_visible_until_new_work_accepts_the_edits() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let documents = app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
let repository = app.add_singleton_model(|ctx| {
|
||||
LocalObjectRepository::new(None, None, ctx)
|
||||
});
|
||||
let document_id = AIDocumentId::new();
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
None,
|
||||
CloudNotebookModel {
|
||||
title: "Reopened plan".to_string(),
|
||||
data: "New unfinished work".to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let archival = app.add_model(PlanArchivalModel::new);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let old_completion = Utc::now() - chrono::Duration::days(8);
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
|
||||
model.archive_plan(&document_id.to_string(), ctx);
|
||||
});
|
||||
assert!(CloudModel::as_ref(&app)
|
||||
.get_notebook(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id
|
||||
.is_some());
|
||||
|
||||
// Typing in the editor emits Dirty; reverting a version also emits a
|
||||
// user-originated DocumentUpdated event. Either must reopen the plan.
|
||||
for event in [
|
||||
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
|
||||
document_id,
|
||||
status: AIDocumentUserEditStatus::Dirty,
|
||||
},
|
||||
AIDocumentModelEvent::DocumentUpdated {
|
||||
document_id,
|
||||
version: Default::default(),
|
||||
source: AIDocumentUpdateSource::User,
|
||||
},
|
||||
] {
|
||||
documents.update(&mut app, |_, ctx| ctx.emit(event));
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
|
||||
model.sweep(ctx);
|
||||
assert!(!model.completed_plans.contains_key(&document_id.to_string()));
|
||||
assert_eq!(
|
||||
model.reopened_plans[&document_id.to_string()].conversation_id,
|
||||
conversation_id,
|
||||
);
|
||||
let json = serde_json::to_string(model).unwrap();
|
||||
let mut restored = PlanArchivalModel::from_persisted(&json).unwrap();
|
||||
restored.observe_completion(
|
||||
document_id.to_string(),
|
||||
conversation_id,
|
||||
old_completion,
|
||||
);
|
||||
assert!(restored.completed_plans.is_empty());
|
||||
});
|
||||
assert!(CloudModel::as_ref(&app)
|
||||
.get_notebook(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id
|
||||
.is_none());
|
||||
}
|
||||
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
// Merely restoring clean editor state cannot close a reopened plan.
|
||||
model.accept_document_edits(document_id, ctx);
|
||||
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
|
||||
model.begin_exchange(conversation_id, ctx);
|
||||
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
|
||||
model.accept_document_edits(document_id, ctx);
|
||||
assert!(model.reopened_plans.is_empty());
|
||||
let finished_again = Utc::now();
|
||||
model.observe_completion(document_id.to_string(), conversation_id, finished_again);
|
||||
assert_eq!(model.completed_plans[&document_id.to_string()].completed_at, finished_again);
|
||||
assert!(!model.completed_plans[&document_id.to_string()].is_due(finished_again));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_draft_conversation_does_not_complete_a_plan() {
|
||||
let mut conversation = AIConversation::new(false, false);
|
||||
conversation.set_status_for_test(ConversationStatus::Success);
|
||||
assert!(completed_work_time(&conversation).is_none());
|
||||
|
||||
for status in [
|
||||
ConversationStatus::InProgress,
|
||||
ConversationStatus::WaitingForEvents,
|
||||
ConversationStatus::TransientError,
|
||||
ConversationStatus::Error,
|
||||
ConversationStatus::Cancelled,
|
||||
] {
|
||||
conversation.set_status_for_test(status);
|
||||
assert!(completed_work_time(&conversation).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archival_preserves_notebook_and_resuming_restores_original_folder() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let (sender, receiver) = sync_channel(32);
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
let repository = app.add_singleton_model(|ctx| {
|
||||
LocalObjectRepository::new(Some(sender), None, ctx)
|
||||
});
|
||||
let archival = app.add_model(|_| PlanArchivalModel {
|
||||
completed_plans: HashMap::new(),
|
||||
..Default::default()
|
||||
});
|
||||
let original_folder = SyncId::ClientId(ClientId::new());
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
let document_id = AIDocumentId::new();
|
||||
let notebook_model = CloudNotebookModel {
|
||||
title: "Retained plan".to_string(),
|
||||
data: "# Plan\n- [x] Finished work".to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
};
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(
|
||||
original_folder,
|
||||
"Project".to_string(),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
Some(original_folder),
|
||||
notebook_model.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let conversation_id = AIConversationId::new();
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(
|
||||
document_id.to_string(),
|
||||
conversation_id,
|
||||
Utc::now() - chrono::Duration::days(8),
|
||||
);
|
||||
model.persist(ctx);
|
||||
});
|
||||
|
||||
// Simulate startup with persisted completion records and no loaded
|
||||
// conversations/documents. The notebook is still retained in Drive.
|
||||
let archival = app.add_model(PlanArchivalModel::new);
|
||||
archival.update(&mut app, |model, ctx| model.sweep(ctx));
|
||||
|
||||
let archive_folder = archival.read(&app, |model, _| {
|
||||
model.completed_plans[&document_id.to_string()]
|
||||
.archived_to
|
||||
.expect("plan should have been archived")
|
||||
});
|
||||
let cloud_model = CloudModel::as_ref(&app);
|
||||
let notebook = cloud_model.get_notebook(¬ebook_id).unwrap();
|
||||
assert_eq!(notebook.model(), ¬ebook_model);
|
||||
assert_eq!(notebook.metadata.folder_id, Some(archive_folder));
|
||||
assert!(!notebook.is_trashed(cloud_model));
|
||||
let folder = cloud_model.get_folder(&archive_folder).unwrap();
|
||||
assert_eq!(folder.model().name, ARCHIVE_FOLDER_NAME);
|
||||
let parent = cloud_model
|
||||
.get_folder(&folder.metadata.folder_id.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(parent.model().name, PLAN_FOLDER_NAME);
|
||||
|
||||
let events = receiver.try_iter().collect::<Vec<_>>();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
ModelEvent::UpsertNotebook { notebook }
|
||||
if notebook.id == notebook_id
|
||||
&& notebook.metadata.folder_id == Some(archive_folder)
|
||||
&& notebook.model() == ¬ebook_model
|
||||
)));
|
||||
assert!(!events.iter().any(|event| matches!(event, ModelEvent::DeleteObjects { .. })));
|
||||
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.resume_conversation(conversation_id, ctx);
|
||||
assert!(model.completed_plans.is_empty());
|
||||
});
|
||||
let cloud_model = CloudModel::as_ref(&app);
|
||||
let notebook = cloud_model.get_notebook(¬ebook_id).unwrap();
|
||||
assert_eq!(notebook.metadata.folder_id, Some(original_folder));
|
||||
assert_eq!(notebook.model(), ¬ebook_model);
|
||||
assert!(!notebook.is_trashed(cloud_model));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_restore_does_not_rearchive_a_completed_plan() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
let repository = app.add_singleton_model(|ctx| {
|
||||
LocalObjectRepository::new(None, None, ctx)
|
||||
});
|
||||
let archive_folder = SyncId::ClientId(ClientId::new());
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
let document_id = AIDocumentId::new();
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(archive_folder, "Archive".to_string(), None, ctx);
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
Some(archive_folder),
|
||||
CloudNotebookModel {
|
||||
title: "Recovered plan".to_string(),
|
||||
data: "Keep this plan visible".to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let mut record = completed_plan(Utc::now() - chrono::Duration::days(8));
|
||||
record.archived_to = Some(archive_folder);
|
||||
let archival = app.add_model(|_| PlanArchivalModel {
|
||||
completed_plans: HashMap::from([(document_id.to_string(), record)]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(notebook_id),
|
||||
CloudObjectLocation::Space(Space::Personal),
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
archival.update(&mut app, |model, ctx| model.sweep(ctx));
|
||||
|
||||
let cloud_model = CloudModel::as_ref(&app);
|
||||
let notebook = cloud_model.get_notebook(¬ebook_id).unwrap();
|
||||
assert!(notebook.metadata.folder_id.is_none());
|
||||
assert_eq!(notebook.model().data, "Keep this plan visible");
|
||||
archival.read(&app, |model, _| {
|
||||
assert!(!model.completed_plans[&document_id.to_string()].is_due(Utc::now()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_notebooks_are_never_archived() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
let repository = app.add_singleton_model(|ctx| {
|
||||
LocalObjectRepository::new(None, None, ctx)
|
||||
});
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
None,
|
||||
CloudNotebookModel {
|
||||
title: "Ordinary notes".to_string(),
|
||||
data: "Keep these notes".to_string(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let archival = app.add_model(|_| PlanArchivalModel {
|
||||
completed_plans: HashMap::new(),
|
||||
..Default::default()
|
||||
});
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(
|
||||
notebook_id.to_string(),
|
||||
AIConversationId::new(),
|
||||
Utc::now() - chrono::Duration::days(8),
|
||||
);
|
||||
model.archive_plan(¬ebook_id.to_string(), ctx);
|
||||
});
|
||||
let cloud_model = CloudModel::as_ref(&app);
|
||||
let notebook = cloud_model.get_notebook(¬ebook_id).unwrap();
|
||||
assert!(notebook.metadata.folder_id.is_none());
|
||||
assert_eq!(notebook.model().data, "Keep these notes");
|
||||
assert_eq!(cloud_model.get_all_active_folders().count(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_an_archived_plan_from_drive_reopens_it_without_loading_its_conversation() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
let repository = app.add_singleton_model(|ctx| {
|
||||
LocalObjectRepository::new(None, None, ctx)
|
||||
});
|
||||
let document_id = AIDocumentId::new();
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
None,
|
||||
CloudNotebookModel {
|
||||
title: "Completed plan".to_string(),
|
||||
data: "- [x] Done".to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let archival = app.add_model(PlanArchivalModel::new);
|
||||
let conversation_id = AIConversationId::new();
|
||||
let completed_at = Utc::now() - chrono::Duration::days(8);
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
|
||||
model.archive_plan(&document_id.to_string(), ctx);
|
||||
});
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_notebook_data(
|
||||
notebook_id,
|
||||
"- [ ] New unfinished work".to_string(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
archival.update(&mut app, |model, ctx| {
|
||||
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
|
||||
model.sweep(ctx);
|
||||
assert!(model.completed_plans.is_empty());
|
||||
assert_eq!(
|
||||
model.reopened_plans[&document_id.to_string()].conversation_id,
|
||||
conversation_id,
|
||||
);
|
||||
});
|
||||
let cloud_model = CloudModel::as_ref(&app);
|
||||
let notebook = cloud_model.get_notebook(¬ebook_id).unwrap();
|
||||
assert!(notebook.metadata.folder_id.is_none());
|
||||
assert_eq!(notebook.model().data, "- [ ] New unfinished work");
|
||||
assert!(!notebook.is_trashed(cloud_model));
|
||||
});
|
||||
}
|
||||
@@ -281,7 +281,7 @@ pub fn render_models_section(
|
||||
.with_child(render_filterable_dropdown_row(
|
||||
appearance,
|
||||
"Base model",
|
||||
"This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.",
|
||||
"This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Galaxy may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.",
|
||||
&view.base_model_dropdown,
|
||||
));
|
||||
|
||||
|
||||
@@ -1377,6 +1377,7 @@ impl AIExecutionProfilesModel {
|
||||
}
|
||||
LocalObjectRepositoryEvent::Rules
|
||||
| LocalObjectRepositoryEvent::Notebooks { .. }
|
||||
| LocalObjectRepositoryEvent::NotebookContentEdited { .. }
|
||||
| LocalObjectRepositoryEvent::Workflows { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
+126
-89
@@ -27,6 +27,8 @@ use warp_multi_agent_api as api;
|
||||
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
|
||||
use super::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::acp::{acp_launch_fingerprint, acp_provider_selection_identity};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::provider::external_config::ExternalBedrockConfig;
|
||||
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
@@ -624,6 +626,7 @@ pub struct LLMPreferences {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
chatgpt_subscription_models_refresh_in_flight: bool,
|
||||
bedrock_models_refresh_in_flight: bool,
|
||||
bedrock_verification_generation: u64,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
bedrock_verified_config: Option<super::provider::client::BedrockClientConfig>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -804,6 +807,7 @@ impl LLMPreferences {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
chatgpt_subscription_models_refresh_in_flight: false,
|
||||
bedrock_models_refresh_in_flight: false,
|
||||
bedrock_verification_generation: 0,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
bedrock_verified_config: None,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -852,7 +856,9 @@ impl LLMPreferences {
|
||||
return;
|
||||
}
|
||||
self.bedrock_models_refresh_in_flight = true;
|
||||
let generation = self.begin_bedrock_verification();
|
||||
let config = bedrock_discovery_config(settings);
|
||||
let revision = super::provider::authentication::connection_revision(ctx);
|
||||
let requested_config = config.clone();
|
||||
self.bedrock_verified_config = None;
|
||||
self.bedrock_verified_model_ids.clear();
|
||||
@@ -868,6 +874,11 @@ impl LLMPreferences {
|
||||
discovery.models
|
||||
});
|
||||
me.bedrock_models_refresh_in_flight = false;
|
||||
if me.bedrock_verification_generation != generation
|
||||
|| super::provider::authentication::connection_revision(ctx) != revision
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !*AISettings::as_ref(ctx).bedrock_enabled.value() {
|
||||
return;
|
||||
}
|
||||
@@ -896,26 +907,37 @@ impl LLMPreferences {
|
||||
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!("[bedrock] Startup model discovery unavailable: {error}");
|
||||
log::warn!("[bedrock] Startup model discovery unavailable: {error}");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn begin_bedrock_verification(&mut self) -> u64 {
|
||||
self.bedrock_verification_generation += 1;
|
||||
self.bedrock_verification_generation
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn record_bedrock_verification(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
config: super::provider::client::BedrockClientConfig,
|
||||
models: &[BedrockModelConfig],
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
) -> bool {
|
||||
if self.bedrock_verification_generation != generation {
|
||||
return false;
|
||||
}
|
||||
self.bedrock_verified_model_ids =
|
||||
models.iter().map(|model| model.model_id.clone()).collect();
|
||||
self.bedrock_verified_config = Some(config);
|
||||
self.inject_bedrock_models(ctx);
|
||||
self.ensure_default_model_present();
|
||||
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -953,48 +975,14 @@ impl LLMPreferences {
|
||||
let region = settings.bedrock_region.value().clone();
|
||||
let cross_region = *settings.bedrock_cross_region_inference.value();
|
||||
|
||||
// Check if user wants only 1-hour cache models
|
||||
use crate::ai::provider::external_config::ExternalBedrockConfig;
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
let require_1h_cache = external_config.enable_prompt_caching_1h;
|
||||
|
||||
let mut effective = discovered_models;
|
||||
|
||||
// Filter out models that don't support 1-hour caching if required
|
||||
if require_1h_cache {
|
||||
effective.retain(|model| {
|
||||
// 1-hour caching is supported by Claude 4.5+ models
|
||||
// Opus 4.5+, Sonnet 4.5+, Haiku 4.5+
|
||||
let supports_1h = model.model_id.contains("4-5")
|
||||
|| model.model_id.contains("4.5")
|
||||
|| model.model_id.contains("-4-6") // Opus/Sonnet 4.6+ also support 1h
|
||||
|| model.model_id.contains("4.6")
|
||||
|| model.model_id.contains("-4-7")
|
||||
|| model.model_id.contains("4.7")
|
||||
|| model.model_id.contains("-4-8")
|
||||
|| model.model_id.contains("4.8");
|
||||
|
||||
if !supports_1h {
|
||||
log::info!(
|
||||
"[bedrock] Filtering out model {} - does not support 1-hour cache (ENABLE_PROMPT_CACHING_1H=1)",
|
||||
model.model_id
|
||||
);
|
||||
}
|
||||
supports_1h
|
||||
});
|
||||
|
||||
if effective.is_empty() {
|
||||
log::warn!("[bedrock] No models left after filtering for 1-hour cache support!");
|
||||
}
|
||||
}
|
||||
|
||||
let effective = effective;
|
||||
for model in effective {
|
||||
for model in discovered_models {
|
||||
if !model.enabled || !self.bedrock_model_is_verified(&model.model_id, ctx) {
|
||||
continue;
|
||||
}
|
||||
let metadata = model_metadata(&model.model_id);
|
||||
if metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false)) {
|
||||
if model.runtime_supported == Some(false)
|
||||
|| metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false))
|
||||
{
|
||||
log::debug!(
|
||||
"[bedrock] Excluding {} from Agent Mode because its catalog metadata marks it as incompatible with streaming Converse",
|
||||
model.model_id
|
||||
@@ -1054,7 +1042,6 @@ impl LLMPreferences {
|
||||
|
||||
// Default agent mode to ANTHROPIC_MODEL from external config if set,
|
||||
// otherwise Claude Opus 4.6, falling back to the first available model.
|
||||
// Note: external_config already loaded above for filtering
|
||||
let external_config_for_default = ExternalBedrockConfig::load();
|
||||
|
||||
if let Some(id) = external_config_for_default
|
||||
@@ -1090,7 +1077,11 @@ impl LLMPreferences {
|
||||
.map(|m| m.id.clone())
|
||||
})
|
||||
{
|
||||
self.models_by_feature.agent_mode.default_id = id;
|
||||
if !self.models_by_feature.agent_mode.choices.iter().any(|model| {
|
||||
model.id == self.models_by_feature.agent_mode.default_id
|
||||
}) {
|
||||
self.models_by_feature.agent_mode.default_id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Default coding to Claude Sonnet 4.6, falling back to the first available model.
|
||||
@@ -1103,12 +1094,18 @@ impl LLMPreferences {
|
||||
.or_else(|| self.models_by_feature.coding.choices.first())
|
||||
.map(|m| m.id.clone())
|
||||
{
|
||||
self.models_by_feature.coding.default_id = id;
|
||||
if !self.models_by_feature.coding.choices.iter().any(|model| {
|
||||
model.id == self.models_by_feature.coding.default_id
|
||||
}) {
|
||||
self.models_by_feature.coding.default_id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Default CLI agent to the first available model.
|
||||
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
||||
if let Some(id) = cli.choices.first().map(|m| m.id.clone()) {
|
||||
if let Some(id) = cli.choices.first().filter(|_| {
|
||||
!cli.choices.iter().any(|model| model.id == cli.default_id)
|
||||
}).map(|m| m.id.clone()) {
|
||||
cli.default_id = id;
|
||||
}
|
||||
}
|
||||
@@ -2854,9 +2851,12 @@ pub(crate) fn openai_model_context_size(
|
||||
.max_input_tokens
|
||||
.unwrap_or(model.context_size)
|
||||
.min(model.context_size);
|
||||
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||
// The catalog's default window is the supported session budget. The
|
||||
// advertised maximum must not silently opt subscriptions into long context.
|
||||
if matches!(
|
||||
provider_kind,
|
||||
OpenAIProviderKind::ChatGPTSubscription | OpenAIProviderKind::LiteLLM
|
||||
) {
|
||||
// Use the efficient session window for both subscriptions and proxies.
|
||||
// An advertised maximum does not opt a connection into long context.
|
||||
// Older saved catalogs kept the default in max_input_tokens instead.
|
||||
return model
|
||||
.default_context_size
|
||||
@@ -2994,7 +2994,7 @@ fn openai_model_context_window(
|
||||
is_configurable: max_context_size > default_context_size,
|
||||
min: default_context_size,
|
||||
max: max_context_size,
|
||||
// Subscription limits are already capped to the catalog's default above.
|
||||
// Subscription and LiteLLM limits are capped to the catalog default above.
|
||||
default_max: max_context_size,
|
||||
}
|
||||
}
|
||||
@@ -3036,6 +3036,53 @@ fn advertised_reasoning_efforts(value: &serde_json::Value) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep an endpoint's recommended window separate from its optional expanded
|
||||
/// maximum. A bare model ID carries no token metadata, so it must not replace
|
||||
/// richer limits when the two catalog endpoints are combined.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn openai_context_limits(metadata: &serde_json::Value) -> (u32, Option<u32>, Option<u32>) {
|
||||
let positive_limit = |keys: &[&str]| {
|
||||
keys.iter().find_map(|key| {
|
||||
metadata[*key]
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.filter(|value| *value > 0)
|
||||
})
|
||||
};
|
||||
let max_input_tokens =
|
||||
positive_limit(&["max_input_tokens", "input_token_limit", "max_prompt_tokens"]);
|
||||
let default_context_size = positive_limit(&[
|
||||
"default_context_size",
|
||||
"default_context_window",
|
||||
"context_window",
|
||||
]);
|
||||
let maximum_context_size = positive_limit(&[
|
||||
"max_context_window",
|
||||
"max_model_len",
|
||||
"context_size",
|
||||
"token_size",
|
||||
]);
|
||||
let effective_context_percent = metadata["effective_context_window_percent"]
|
||||
.as_u64()
|
||||
.filter(|value| (1..=100).contains(value))
|
||||
.unwrap_or(100);
|
||||
// A proxy may already reduce max_input_tokens by this percentage. Apply it
|
||||
// only to the original session window, never to that input limit again.
|
||||
let default_context_size = default_context_size
|
||||
.map(|value| {
|
||||
u32::try_from(u64::from(value) * effective_context_percent / 100)
|
||||
.unwrap_or(value)
|
||||
.max(1)
|
||||
})
|
||||
.or(max_input_tokens)
|
||||
.or(maximum_context_size);
|
||||
let context_size = maximum_context_size
|
||||
.or(default_context_size)
|
||||
.or(max_input_tokens)
|
||||
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
|
||||
(context_size, max_input_tokens, default_context_size)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn normalize_codex_release_version(version: &str) -> Option<String> {
|
||||
let version = version.trim();
|
||||
@@ -3264,19 +3311,11 @@ async fn fetch_from_litellm_model_info(
|
||||
let model_name = entry["model_name"].as_str()?;
|
||||
let model_info = &entry["model_info"];
|
||||
|
||||
let max_input_tokens = model_info["max_input_tokens"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok());
|
||||
let (context_size, max_input_tokens, default_context_size) =
|
||||
openai_context_limits(model_info);
|
||||
let max_output_tokens = model_info["max_output_tokens"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok());
|
||||
let context_size = u32_from_json_any(
|
||||
model_info,
|
||||
&["max_context_window", "context_window", "max_model_len"],
|
||||
)
|
||||
.or(max_input_tokens)
|
||||
.unwrap_or(200_000);
|
||||
|
||||
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true);
|
||||
|
||||
let display_name = model_name.replace(['-', '_'], " ");
|
||||
@@ -3326,7 +3365,7 @@ async fn fetch_from_litellm_model_info(
|
||||
vision_supported,
|
||||
context_size,
|
||||
max_input_tokens,
|
||||
default_context_size: None,
|
||||
default_context_size,
|
||||
max_output_tokens,
|
||||
provider,
|
||||
supports_system_messages: if model_name.starts_with("codex-gpt-") {
|
||||
@@ -3373,12 +3412,32 @@ async fn fetch_litellm_catalog(
|
||||
metadata
|
||||
.iter()
|
||||
.find(|info| info.model_id == model.model_id)
|
||||
.cloned()
|
||||
.map(|mut info| {
|
||||
if info.reasoning_efforts.is_empty() {
|
||||
info.reasoning_efforts = model.reasoning_efforts.clone();
|
||||
.map(|info| {
|
||||
let mut enriched = info.clone();
|
||||
// /model/info supplies capabilities, but must not erase a
|
||||
// smaller session budget supplied by /models (or vice versa).
|
||||
enriched.default_context_size = model
|
||||
.default_context_size
|
||||
.into_iter()
|
||||
.chain(info.default_context_size)
|
||||
.min();
|
||||
enriched.max_input_tokens = model
|
||||
.max_input_tokens
|
||||
.into_iter()
|
||||
.chain(info.max_input_tokens)
|
||||
.min();
|
||||
enriched.context_size = match (
|
||||
model.default_context_size,
|
||||
info.default_context_size,
|
||||
) {
|
||||
(Some(_), Some(_)) => model.context_size.max(info.context_size),
|
||||
(Some(_), None) => model.context_size,
|
||||
_ => info.context_size,
|
||||
};
|
||||
if enriched.reasoning_efforts.is_empty() {
|
||||
enriched.reasoning_efforts = model.reasoning_efforts.clone();
|
||||
}
|
||||
info
|
||||
enriched
|
||||
})
|
||||
.unwrap_or(model)
|
||||
})
|
||||
@@ -3430,21 +3489,7 @@ async fn fetch_from_openai_models(
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?;
|
||||
let max_input_tokens = u32_from_any(
|
||||
m,
|
||||
&["max_input_tokens", "input_token_limit", "max_prompt_tokens"],
|
||||
);
|
||||
let context_size = u32_from_any(
|
||||
m,
|
||||
&[
|
||||
"max_context_window",
|
||||
"max_model_len",
|
||||
"context_window",
|
||||
"token_size",
|
||||
],
|
||||
)
|
||||
.or(max_input_tokens)
|
||||
.unwrap_or(200_000);
|
||||
let (context_size, max_input_tokens, default_context_size) = openai_context_limits(m);
|
||||
let max_output_tokens = u32_from_any(
|
||||
m,
|
||||
&[
|
||||
@@ -3491,7 +3536,7 @@ async fn fetch_from_openai_models(
|
||||
.unwrap_or(true),
|
||||
context_size,
|
||||
max_input_tokens,
|
||||
default_context_size: None,
|
||||
default_context_size,
|
||||
max_output_tokens,
|
||||
provider,
|
||||
supports_system_messages: if id.starts_with("codex-gpt-") {
|
||||
@@ -3529,13 +3574,5 @@ mod tests;
|
||||
fn bedrock_discovery_config(
|
||||
settings: &AISettings,
|
||||
) -> crate::ai::provider::client::BedrockClientConfig {
|
||||
crate::ai::provider::client::BedrockClientConfig {
|
||||
auth_method: *settings.bedrock_auth_method.value(),
|
||||
profile: settings.bedrock_profile.value().clone(),
|
||||
region: settings.bedrock_region.value().clone(),
|
||||
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
||||
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
||||
session_token: None,
|
||||
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
|
||||
}
|
||||
crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use serde_json::json;
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
@@ -159,6 +160,51 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_bedrock_models_are_not_hidden_by_missing_catalog_metadata() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let id = "future.new-live-model";
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings.bedrock_enabled.set_value(true, ctx).unwrap();
|
||||
settings.bedrock_models.set_value(
|
||||
vec![bedrock_model("future.other-model"), bedrock_model(id)], ctx,
|
||||
).unwrap();
|
||||
});
|
||||
let mut preferences = empty_preferences();
|
||||
preferences.models_by_feature.agent_mode.default_id = id.into();
|
||||
preferences.models_by_feature.coding.default_id = id.into();
|
||||
app.read(|ctx| {
|
||||
preferences.bedrock_verified_config =
|
||||
Some(super::bedrock_discovery_config(AISettings::as_ref(ctx)));
|
||||
preferences.bedrock_verified_model_ids.insert(id.to_owned());
|
||||
preferences.bedrock_verified_model_ids.insert("future.other-model".to_owned());
|
||||
preferences.inject_bedrock_models(ctx);
|
||||
});
|
||||
assert!(preferences.models_by_feature.agent_mode.choices.iter().any(|model| model.id.as_str() == id));
|
||||
assert_eq!(preferences.models_by_feature.agent_mode.default_id.as_str(), id);
|
||||
assert_eq!(preferences.models_by_feature.coding.default_id.as_str(), id);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_bedrock_discovery_invalidates_older_results() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let preferences = app.add_model(|_| empty_preferences());
|
||||
let models = [bedrock_model("future.new-live-model")];
|
||||
preferences.update(&mut app, |preferences, ctx| {
|
||||
let first = preferences.begin_bedrock_verification();
|
||||
let latest = preferences.begin_bedrock_verification();
|
||||
let config = super::bedrock_discovery_config(AISettings::as_ref(ctx));
|
||||
assert!(!preferences.record_bedrock_verification(first, config.clone(), &models, ctx));
|
||||
assert!(preferences.bedrock_verified_model_ids.is_empty());
|
||||
assert!(preferences.record_bedrock_verification(latest, config, &models, ctx));
|
||||
assert!(preferences.bedrock_verified_model_ids.contains("future.new-live-model"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bedrock_model(model_id: &str) -> BedrockModelConfig {
|
||||
BedrockModelConfig {
|
||||
enabled: true,
|
||||
@@ -166,6 +212,7 @@ fn bedrock_model(model_id: &str) -> BedrockModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
display_name: model_id.to_string(),
|
||||
vision_supported: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +282,7 @@ fn empty_preferences() -> LLMPreferences {
|
||||
catalog_refresh_generations: HashMap::new(),
|
||||
chatgpt_subscription_models_refresh_in_flight: false,
|
||||
bedrock_models_refresh_in_flight: false,
|
||||
bedrock_verification_generation: 0,
|
||||
bedrock_verified_config: None,
|
||||
bedrock_verified_model_ids: HashSet::new(),
|
||||
acp_selections: HashMap::new(),
|
||||
@@ -452,6 +500,7 @@ fn acp_models_are_injected_only_while_acp_is_enabled() {
|
||||
catalog_refresh_generations: HashMap::new(),
|
||||
chatgpt_subscription_models_refresh_in_flight: false,
|
||||
bedrock_models_refresh_in_flight: false,
|
||||
bedrock_verification_generation: 0,
|
||||
bedrock_verified_config: None,
|
||||
bedrock_verified_model_ids: HashSet::new(),
|
||||
acp_selections: HashMap::new(),
|
||||
@@ -1142,6 +1191,155 @@ fn chatgpt_default_window_limits_routing_without_a_separate_input_limit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn litellm_efficient_windows_limit_model_picker_and_provider_routing() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let models = [("terminal-model", 272_000), ("main-model", 500_000)]
|
||||
.into_iter()
|
||||
.map(|(id, efficient_window)| {
|
||||
let mut model = openai_model(id);
|
||||
model.context_size = 872_000;
|
||||
model.max_input_tokens = Some(872_000);
|
||||
model.default_context_size = Some(efficient_window);
|
||||
model
|
||||
})
|
||||
.collect();
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings.openai_enabled.set_value(true, ctx).unwrap();
|
||||
settings
|
||||
.openai_providers
|
||||
.set_value(
|
||||
vec![OpenAIProviderConfig {
|
||||
kind: OpenAIProviderKind::LiteLLM,
|
||||
enabled: true,
|
||||
name: "LiteLLM".to_owned(),
|
||||
base_url: "https://proxy.test/v1".to_owned(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
models,
|
||||
}],
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut preferences = empty_preferences();
|
||||
app.read(|ctx| preferences.inject_openai_models(ctx));
|
||||
for (id, efficient_window) in [("terminal-model", 272_000), ("main-model", 500_000)] {
|
||||
let route = preferences.openai_client_config_for_model(id).unwrap();
|
||||
assert_eq!(route.max_input_tokens, Some(efficient_window));
|
||||
let model = preferences
|
||||
.models_by_feature
|
||||
.agent_mode
|
||||
.choices
|
||||
.iter()
|
||||
.find(|model| model.id.as_str() == id)
|
||||
.unwrap();
|
||||
assert_eq!(model.context_window.max, efficient_window);
|
||||
assert_eq!(model.context_window.default_max, efficient_window);
|
||||
assert!(!model.context_window.is_configurable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn litellm_discovery_preserves_efficient_windows_across_catalog_endpoints() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
for (model_metadata, rich_metadata, maximum, efficient_window, request_budget) in [
|
||||
(
|
||||
json!({"id": "model"}),
|
||||
Some(json!({
|
||||
"context_window": 500000,
|
||||
"max_context_window": 872000,
|
||||
"max_input_tokens": 872000
|
||||
})),
|
||||
872_000,
|
||||
500_000,
|
||||
500_000,
|
||||
),
|
||||
(
|
||||
json!({"id": "model", "context_window": 272000, "max_context_window": 872000}),
|
||||
Some(json!({"max_context_window": 872000})),
|
||||
872_000,
|
||||
272_000,
|
||||
272_000,
|
||||
),
|
||||
(
|
||||
json!({"id": "model", "context_window": 272000, "max_context_window": 872000}),
|
||||
None,
|
||||
872_000,
|
||||
272_000,
|
||||
272_000,
|
||||
),
|
||||
(
|
||||
json!({"id": "model", "context_window": 500000, "max_context_window": 872000}),
|
||||
Some(json!({"supports_vision": true})),
|
||||
872_000,
|
||||
500_000,
|
||||
500_000,
|
||||
),
|
||||
(
|
||||
json!({"id": "model"}),
|
||||
Some(json!({
|
||||
"context_window": 272000,
|
||||
"max_context_window": 872000,
|
||||
"max_input_tokens": 258400,
|
||||
"effective_context_window_percent": 95
|
||||
})),
|
||||
872_000,
|
||||
258_400,
|
||||
258_400,
|
||||
),
|
||||
(
|
||||
json!({"id": "model"}),
|
||||
Some(json!({
|
||||
"context_window": 272000,
|
||||
"max_context_window": 872000,
|
||||
"max_input_tokens": 128000
|
||||
})),
|
||||
872_000,
|
||||
272_000,
|
||||
128_000,
|
||||
),
|
||||
] {
|
||||
let catalog = server
|
||||
.mock("GET", "/models")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({"data": [model_metadata]}).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
let metadata = server
|
||||
.mock("GET", "/model/info")
|
||||
.with_status(if rich_metadata.is_some() { 200 } else { 404 })
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
json!({"data": [{"model_name": "model", "model_info": rich_metadata}]}).to_string(),
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let models = fetch_litellm_catalog(&server.url(), None, &reqwest::Client::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].context_size, maximum);
|
||||
assert_eq!(models[0].default_context_size, Some(efficient_window));
|
||||
assert_eq!(
|
||||
openai_model_context_size(&models[0], OpenAIProviderKind::LiteLLM),
|
||||
request_budget
|
||||
);
|
||||
let window = openai_model_context_window(&models[0], OpenAIProviderKind::LiteLLM);
|
||||
assert_eq!(window.default_max, request_budget);
|
||||
assert_eq!(window.max, request_budget);
|
||||
assert!(!window.is_configurable);
|
||||
catalog.assert_async().await;
|
||||
metadata.assert_async().await;
|
||||
catalog.remove_async().await;
|
||||
metadata.remove_async().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn litellm_refresh_replaces_membership_and_preserves_preferences() {
|
||||
let mut retained = openai_model("retained");
|
||||
|
||||
+3
-1
@@ -85,10 +85,12 @@ pub mod outline;
|
||||
pub(crate) use ai::paths;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(provider::authentication::BedrockAuthentication::new);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
|
||||
app.add_singleton_model(chatgpt_auth::ChatGPTAuthModel::new);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
|
||||
blocklist::keyboard_navigable_buttons::init(app);
|
||||
|
||||
@@ -663,6 +663,8 @@ fn estimate_cost_cents(
|
||||
}
|
||||
|
||||
const KNOWN_TOOLS: &[&str] = &[
|
||||
"request_computer_use",
|
||||
"use_computer",
|
||||
"run_shell_command",
|
||||
"read_files",
|
||||
"apply_file_diffs",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::{BoxFuture, Shared};
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
use settings::Setting;
|
||||
|
||||
use super::client::{BedrockClientConfig, BedrockError};
|
||||
use crate::ai::agent::RenderableAIError;
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent, BedrockAuthMethod};
|
||||
|
||||
impl BedrockClientConfig {
|
||||
pub(crate) fn from_settings(settings: &AISettings) -> Self {
|
||||
Self {
|
||||
auth_method: *settings.bedrock_auth_method.value(),
|
||||
profile: settings.bedrock_profile.value().clone(),
|
||||
region: settings.bedrock_region.value().clone(),
|
||||
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
||||
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
||||
session_token: None,
|
||||
credentials_expiry: None,
|
||||
invocation_model_id: None,
|
||||
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_model_route(
|
||||
mut self,
|
||||
model_id: &str,
|
||||
models: &[crate::settings::BedrockModelConfig],
|
||||
) -> Self {
|
||||
self.invocation_model_id = models.iter().find(|model| {
|
||||
model.model_id == model_id
|
||||
|| model.invocation_model_id == model_id
|
||||
|| super::models::foundation_model_id(&model.model_id)
|
||||
== super::models::foundation_model_id(model_id)
|
||||
}).and_then(|model| {
|
||||
(!model.invocation_model_id.is_empty()).then(|| model.invocation_model_id.clone())
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_managed_credentials(
|
||||
mut self,
|
||||
strategy: &AwsCredentialsRefreshStrategy,
|
||||
state: &AwsCredentialsState,
|
||||
) -> Self {
|
||||
match strategy {
|
||||
AwsCredentialsRefreshStrategy::LocalChain => self,
|
||||
AwsCredentialsRefreshStrategy::OidcManaged { region, .. } => {
|
||||
self.auth_method = BedrockAuthMethod::StaticKeys;
|
||||
self.region.clone_from(region);
|
||||
self.access_key_id.clear();
|
||||
self.secret_access_key.clear();
|
||||
self.session_token = None;
|
||||
self.credentials_expiry = None;
|
||||
if let AwsCredentialsState::Loaded { credentials, .. } = state {
|
||||
self.access_key_id = credentials.access_key().to_owned();
|
||||
self.secret_access_key = credentials.secret_key().to_owned();
|
||||
self.session_token = credentials.session_token().map(str::to_owned);
|
||||
self.credentials_expiry = Some(
|
||||
credentials.expires_at().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn credentials_are_current(expiry: Option<SystemTime>, now: SystemTime) -> bool {
|
||||
expiry.is_none_or(|expiry| expiry > now)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_startup_error(error: &anyhow::Error, model: &str) -> RenderableAIError {
|
||||
match error.downcast_ref::<BedrockError>() {
|
||||
Some(BedrockError::CredentialsNotConfigured | BedrockError::CredentialsLoadFailed(_)) => {
|
||||
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
|
||||
model_name: model.to_owned(),
|
||||
}
|
||||
}
|
||||
Some(
|
||||
BedrockError::RegionNotConfigured
|
||||
| BedrockError::ApiError(_)
|
||||
| BedrockError::ModelNotFound(_)
|
||||
| BedrockError::AccessDenied(_)
|
||||
| BedrockError::Throttling(_)
|
||||
| BedrockError::ValidationError(_),
|
||||
) | None => RenderableAIError::other(error.to_string(), false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn login_command(
|
||||
configured: &str,
|
||||
profile: &str,
|
||||
) -> String {
|
||||
if configured != "aws sso login" {
|
||||
return configured.to_owned();
|
||||
}
|
||||
if profile.is_empty() {
|
||||
configured.to_owned()
|
||||
} else {
|
||||
format!("aws sso login --profile {}", shell_words::quote(profile))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn login_command_parts(command: &str) -> Result<Vec<String>, String> {
|
||||
let parts = shell_words::split(command)
|
||||
.map_err(|_| "AWS login command contains invalid quoting".to_owned())?;
|
||||
if parts.is_empty() {
|
||||
return Err("AWS login command is not configured".to_owned());
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct BedrockAuthConnection {
|
||||
pub(crate) config: BedrockClientConfig,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) auto_login: bool,
|
||||
pub(crate) command: String,
|
||||
}
|
||||
|
||||
impl BedrockAuthConnection {
|
||||
pub(crate) fn current(ctx: &AppContext) -> Self {
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let config = BedrockClientConfig::from_settings(settings);
|
||||
Self {
|
||||
command: match config.auth_method {
|
||||
BedrockAuthMethod::Sso => login_command(
|
||||
settings.bedrock_auth_refresh_command.value(),
|
||||
&config.profile,
|
||||
),
|
||||
BedrockAuthMethod::Profile | BedrockAuthMethod::StaticKeys => String::new(),
|
||||
},
|
||||
config,
|
||||
enabled: *settings.bedrock_enabled.value(),
|
||||
auto_login: *settings.bedrock_auto_login.value(),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(&self) -> String {
|
||||
format!(
|
||||
"{:?}:{}:{}:{}",
|
||||
self.config.auth_method, self.config.profile, self.config.region, self.command
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecoveryAttempts {
|
||||
exchanges: HashMap<String, (String, String)>,
|
||||
}
|
||||
|
||||
impl RecoveryAttempts {
|
||||
fn begin(&mut self, conversation: &str, exchange: &str, connection: &str, manual: bool) -> bool {
|
||||
let attempt = (exchange.to_owned(), connection.to_owned());
|
||||
if !manual && self.exchanges.get(conversation) == Some(&attempt) {
|
||||
return false;
|
||||
}
|
||||
self.exchanges.insert(conversation.to_owned(), attempt);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
type CredentialResult = Result<AwsCredentials, String>;
|
||||
type CredentialFuture = Shared<BoxFuture<'static, CredentialResult>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BedrockAuthentication {
|
||||
attempts: RecoveryAttempts,
|
||||
active: Vec<(u64, BedrockAuthConnection, CredentialFuture)>,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl BedrockAuthentication {
|
||||
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, _| {
|
||||
if matches!(
|
||||
event,
|
||||
AISettingsChangedEvent::BedrockEnabled { .. }
|
||||
| AISettingsChangedEvent::BedrockAuthMethod { .. }
|
||||
| AISettingsChangedEvent::BedrockProfile { .. }
|
||||
| AISettingsChangedEvent::BedrockRegion { .. }
|
||||
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
|
||||
| AISettingsChangedEvent::BedrockAccessKeyId { .. }
|
||||
| AISettingsChangedEvent::BedrockSecretAccessKey { .. }
|
||||
) {
|
||||
me.revision += 1;
|
||||
me.active.clear();
|
||||
}
|
||||
});
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
pub(crate) fn recover(
|
||||
&mut self,
|
||||
conversation: &str,
|
||||
exchange: &str,
|
||||
manual: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> BoxFuture<'static, Result<(), String>> {
|
||||
let connection = BedrockAuthConnection::current(ctx);
|
||||
if !connection.enabled || (!manual && !connection.auto_login) {
|
||||
return Box::pin(async { Err("Automatic AWS login is disabled".to_owned()) });
|
||||
}
|
||||
if !self.attempts.begin(conversation, exchange, &connection.identity(), manual) {
|
||||
return Box::pin(async {
|
||||
Err("AWS authentication still failed after recovery. Use Refresh AWS Credentials to retry.".to_owned())
|
||||
});
|
||||
}
|
||||
let strategy = ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy();
|
||||
if matches!(strategy, AwsCredentialsRefreshStrategy::OidcManaged { .. }) {
|
||||
return ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
|
||||
});
|
||||
}
|
||||
if connection.config.auth_method == BedrockAuthMethod::StaticKeys {
|
||||
return Box::pin(async {
|
||||
Err("Update the configured AWS static keys to authenticate.".to_owned())
|
||||
});
|
||||
}
|
||||
|
||||
let (generation, future) = match self.active.iter().find(|(_, active, _)| active == &connection) {
|
||||
Some((generation, _, future)) => (*generation, future.clone()),
|
||||
None => {
|
||||
self.generation += 1;
|
||||
let config = connection.config.clone();
|
||||
let command = connection.command.clone();
|
||||
let future = recover_credentials(
|
||||
connection.config.auth_method,
|
||||
manual,
|
||||
move || {
|
||||
let config = config.clone();
|
||||
async move {
|
||||
crate::ai::aws_credentials::load_aws_credentials_from_sdk(&config)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}.boxed()
|
||||
},
|
||||
move || {
|
||||
let command = command.clone();
|
||||
async move {
|
||||
let parts = login_command_parts(&command)?;
|
||||
let status = tokio::process::Command::new(&parts[0])
|
||||
.args(&parts[1..])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("Failed to start AWS login: {error}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("AWS login command exited with status: {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}.boxed()
|
||||
},
|
||||
).boxed().shared();
|
||||
self.active.push((self.generation, connection.clone(), future.clone()));
|
||||
(self.generation, future)
|
||||
}
|
||||
};
|
||||
let revision = self.revision;
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = ctx.spawn(future, move |me, result, ctx| {
|
||||
me.active.retain(|(active, _, _)| *active != generation);
|
||||
if me.revision != revision
|
||||
|| BedrockAuthConnection::current(ctx) != connection
|
||||
|| ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy() != strategy
|
||||
{
|
||||
let _ = tx.send(Err("AWS connection changed during authentication".to_owned()));
|
||||
return;
|
||||
}
|
||||
let outcome = result.as_ref().map(|_| ()).map_err(Clone::clone);
|
||||
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
let state = match result {
|
||||
Ok(credentials) => AwsCredentialsState::Loaded {
|
||||
credentials,
|
||||
loaded_at: SystemTime::now(),
|
||||
},
|
||||
Err(message) => AwsCredentialsState::Failed { message },
|
||||
};
|
||||
manager.set_aws_credentials_state(state, ctx);
|
||||
});
|
||||
let _ = tx.send(outcome);
|
||||
});
|
||||
async move {
|
||||
rx.await.unwrap_or_else(|_| Err("AWS authentication was interrupted".to_owned()))
|
||||
}.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
async fn recover_credentials(
|
||||
auth_method: BedrockAuthMethod,
|
||||
login_first: bool,
|
||||
load: impl Fn() -> BoxFuture<'static, CredentialResult>,
|
||||
login: impl Fn() -> BoxFuture<'static, Result<(), String>>,
|
||||
) -> CredentialResult {
|
||||
match auth_method {
|
||||
BedrockAuthMethod::Profile => return load().await,
|
||||
BedrockAuthMethod::StaticKeys => {
|
||||
return Err("Update the configured AWS static keys to authenticate.".to_owned());
|
||||
}
|
||||
BedrockAuthMethod::Sso => {}
|
||||
}
|
||||
if !login_first {
|
||||
if let Ok(credentials) = load().await {
|
||||
return Ok(credentials);
|
||||
}
|
||||
}
|
||||
login().await?;
|
||||
load().await
|
||||
}
|
||||
|
||||
impl Entity for BedrockAuthentication {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for BedrockAuthentication {}
|
||||
|
||||
pub(crate) fn connection_revision(ctx: &AppContext) -> u64 {
|
||||
ctx.try_get_singleton_model_as_ref::<BedrockAuthentication>()
|
||||
.map_or(0, |authentication| authentication.revision())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "authentication_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,294 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use ai::api_keys::{AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn config(auth_method: BedrockAuthMethod) -> BedrockClientConfig {
|
||||
BedrockClientConfig {
|
||||
auth_method,
|
||||
profile: "default".to_owned(),
|
||||
region: String::new(),
|
||||
access_key_id: "configured-key".to_owned(),
|
||||
secret_access_key: "configured-secret".to_owned(),
|
||||
session_token: None,
|
||||
credentials_expiry: None,
|
||||
invocation_model_id: None,
|
||||
cross_region_inference: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_profile_and_region_are_authoritative_including_defaults() {
|
||||
galaxyui::App::test((), |mut app| async move {
|
||||
crate::test_util::settings::initialize_settings_for_tests(&mut app);
|
||||
for (profile, region) in [("default", ""), ("selected-profile", "eu-west-1")] {
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings.bedrock_profile.set_value(profile.to_owned(), ctx).unwrap();
|
||||
settings.bedrock_region.set_value(region.to_owned(), ctx).unwrap();
|
||||
});
|
||||
app.read(|ctx| {
|
||||
let resolved = BedrockClientConfig::from_settings(AISettings::as_ref(ctx));
|
||||
assert_eq!(resolved.profile, profile);
|
||||
assert_eq!(resolved.region, region);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_login_commands_are_not_rewritten() {
|
||||
for command in ["", "company-login --interactive", "aws sso login --profile custom"] {
|
||||
assert_eq!(login_command(command, "selected"), command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_login_targets_and_quotes_the_effective_profile() {
|
||||
assert_eq!(
|
||||
login_command("aws sso login", "default"),
|
||||
"aws sso login --profile default"
|
||||
);
|
||||
let command = login_command(
|
||||
"aws sso login",
|
||||
"team profile",
|
||||
);
|
||||
assert_eq!(
|
||||
login_command_parts(&command).unwrap(),
|
||||
["aws", "sso", "login", "--profile", "team profile"]
|
||||
);
|
||||
assert!(login_command_parts("").is_err());
|
||||
assert!(login_command_parts("aws 'unterminated").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_credential_snapshots_never_override_configured_authentication() {
|
||||
let now = SystemTime::now();
|
||||
for auth in [
|
||||
BedrockAuthMethod::Profile,
|
||||
BedrockAuthMethod::Sso,
|
||||
BedrockAuthMethod::StaticKeys,
|
||||
] {
|
||||
for expiry in [None, Some(now - Duration::from_secs(1)), Some(now + Duration::from_secs(60))] {
|
||||
let configured = config(auth);
|
||||
let loaded = AwsCredentialsState::Loaded {
|
||||
credentials: AwsCredentials::new(
|
||||
"snapshot-key".to_owned(),
|
||||
"snapshot-secret".to_owned(),
|
||||
Some("snapshot-token".to_owned()),
|
||||
expiry,
|
||||
),
|
||||
loaded_at: now,
|
||||
};
|
||||
assert_eq!(
|
||||
configured.clone().with_managed_credentials(
|
||||
&AwsCredentialsRefreshStrategy::LocalChain,
|
||||
&loaded,
|
||||
),
|
||||
configured
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_oidc_credentials_preserve_expiry_and_do_not_fall_back_to_local_keys() {
|
||||
let now = SystemTime::now();
|
||||
let strategy = AwsCredentialsRefreshStrategy::OidcManaged {
|
||||
task_id: Some("task".to_owned()),
|
||||
role_arn: "test-role".to_owned(),
|
||||
region: "us-east-1".to_owned(),
|
||||
};
|
||||
let loaded = AwsCredentialsState::Loaded {
|
||||
credentials: AwsCredentials::new(
|
||||
"managed-key".to_owned(),
|
||||
"managed-secret".to_owned(),
|
||||
Some("managed-token".to_owned()),
|
||||
Some(now + Duration::from_secs(60)),
|
||||
),
|
||||
loaded_at: now,
|
||||
};
|
||||
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &loaded);
|
||||
assert_eq!(resolved.auth_method, BedrockAuthMethod::StaticKeys);
|
||||
assert_eq!(resolved.region, "us-east-1");
|
||||
assert_eq!(resolved.access_key_id, "managed-key");
|
||||
assert_eq!(resolved.credentials_expiry, Some(now + Duration::from_secs(60)));
|
||||
for state in [
|
||||
AwsCredentialsState::Missing,
|
||||
AwsCredentialsState::Disabled,
|
||||
AwsCredentialsState::Refreshing,
|
||||
AwsCredentialsState::Failed { message: "failed".to_owned() },
|
||||
] {
|
||||
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &state);
|
||||
assert!(resolved.access_key_id.is_empty());
|
||||
assert!(resolved.secret_access_key.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_recovery_is_bounded_per_user_exchange_but_manual_retry_is_available() {
|
||||
let mut attempts = RecoveryAttempts::default();
|
||||
assert!(attempts.begin("conversation", "query-1", "connection", false));
|
||||
assert!(!attempts.begin("conversation", "query-1", "connection", false));
|
||||
assert!(attempts.begin("conversation", "query-1", "connection", true));
|
||||
assert!(attempts.begin("conversation", "query-2", "connection", false));
|
||||
assert!(attempts.begin("conversation", "query-2", "new-connection", false));
|
||||
assert!(attempts.begin("another-conversation", "query-2", "connection", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_expiry_is_checked_at_the_boundary() {
|
||||
let now = SystemTime::now();
|
||||
assert!(credentials_are_current(None, now));
|
||||
assert!(credentials_are_current(Some(now + Duration::from_secs(1)), now));
|
||||
assert!(!credentials_are_current(Some(now), now));
|
||||
assert!(!credentials_are_current(Some(now - Duration::from_secs(1)), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_routes_use_discovered_ids_and_match_legacy_cross_region_selections() {
|
||||
let model = crate::settings::BedrockModelConfig {
|
||||
model_id: "future.new-model".to_owned(),
|
||||
invocation_model_id: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.future.new-model".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
for selected in ["future.new-model", "us.future.new-model"] {
|
||||
let resolved = config(BedrockAuthMethod::Profile)
|
||||
.with_model_route(selected, &[model.clone()]);
|
||||
assert_eq!(resolved.invocation_model_id.as_deref(), Some(model.invocation_model_id.as_str()));
|
||||
}
|
||||
let resolved = config(BedrockAuthMethod::Profile).with_model_route("unrelated.model", &[model]);
|
||||
assert_eq!(resolved.invocation_model_id, None);
|
||||
}
|
||||
|
||||
fn test_credentials() -> AwsCredentials {
|
||||
AwsCredentials::new("test-key".to_owned(), "test-secret".to_owned(), None, None)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_refresh_recovers_without_running_login() {
|
||||
let credentials = recover_credentials(
|
||||
BedrockAuthMethod::Sso,
|
||||
false,
|
||||
|| futures::future::ready(Ok(test_credentials())).boxed(),
|
||||
|| futures::future::ready(Err("Login must not run".to_owned())).boxed(),
|
||||
).await.unwrap();
|
||||
assert_eq!(credentials, test_credentials());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_session_runs_login_then_awaits_a_fresh_reload() {
|
||||
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let load_calls = calls.clone();
|
||||
let login_calls = calls.clone();
|
||||
let result = recover_credentials(
|
||||
BedrockAuthMethod::Sso,
|
||||
false,
|
||||
move || {
|
||||
let index = load_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let result = match index {
|
||||
0 => Err("expired".to_owned()),
|
||||
2 => Ok(test_credentials()),
|
||||
index => panic!("Unexpected reload order: {index}"),
|
||||
};
|
||||
futures::future::ready(result).boxed()
|
||||
},
|
||||
move || {
|
||||
assert_eq!(login_calls.fetch_add(1, Ordering::SeqCst), 1);
|
||||
futures::future::ready(Ok(())).boxed()
|
||||
},
|
||||
).await;
|
||||
assert_eq!(result, Ok(test_credentials()));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_login_failure_does_not_reload_or_resume() {
|
||||
let result = recover_credentials(
|
||||
BedrockAuthMethod::Sso,
|
||||
true,
|
||||
|| panic!("Credentials must not reload after failed login"),
|
||||
|| futures::future::ready(Err("login failed".to_owned())).boxed(),
|
||||
).await;
|
||||
assert_eq!(result, Err("login failed".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_success_with_failed_reload_remains_an_error() {
|
||||
let result = recover_credentials(
|
||||
BedrockAuthMethod::Sso,
|
||||
true,
|
||||
|| futures::future::ready(Err("reload failed".to_owned())).boxed(),
|
||||
|| futures::future::ready(Ok(())).boxed(),
|
||||
).await;
|
||||
assert_eq!(result, Err("reload failed".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_recovery_executes_login_once_for_overlapping_waiters() {
|
||||
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let login_calls = calls.clone();
|
||||
let recovery = recover_credentials(
|
||||
BedrockAuthMethod::Sso,
|
||||
true,
|
||||
|| futures::future::ready(Ok(test_credentials())).boxed(),
|
||||
move || {
|
||||
login_calls.fetch_add(1, Ordering::SeqCst);
|
||||
futures::future::ready(Ok(())).boxed()
|
||||
},
|
||||
).boxed().shared();
|
||||
let (first, second) = futures::join!(recovery.clone(), recovery);
|
||||
assert_eq!(first, Ok(test_credentials()));
|
||||
assert_eq!(second, Ok(test_credentials()));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_preparation_failures_surface_authentication_recovery() {
|
||||
use super::super::client::BedrockError;
|
||||
|
||||
for error in [
|
||||
BedrockError::CredentialsNotConfigured,
|
||||
BedrockError::CredentialsLoadFailed("SSO session invalid".to_owned()),
|
||||
] {
|
||||
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
|
||||
assert!(error.is_aws_bedrock_credentials_error());
|
||||
}
|
||||
for error in [
|
||||
BedrockError::RegionNotConfigured,
|
||||
BedrockError::ApiError("transport".to_owned()),
|
||||
BedrockError::ModelNotFound("model".to_owned()),
|
||||
BedrockError::AccessDenied("policy".to_owned()),
|
||||
BedrockError::Throttling("busy".to_owned()),
|
||||
BedrockError::ValidationError("request".to_owned()),
|
||||
] {
|
||||
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
|
||||
assert!(!error.is_aws_bedrock_credentials_error());
|
||||
}
|
||||
assert!(!provider_startup_error(&anyhow::anyhow!("unrelated"), "selected-model")
|
||||
.is_aws_bedrock_credentials_error());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_authentication_never_starts_a_login_flow() {
|
||||
for manual in [false, true] {
|
||||
let result = recover_credentials(
|
||||
BedrockAuthMethod::Profile,
|
||||
manual,
|
||||
|| futures::future::ready(Ok(test_credentials())).boxed(),
|
||||
|| panic!("Profile authentication must not execute login commands"),
|
||||
).await;
|
||||
assert_eq!(result, Ok(test_credentials()));
|
||||
let result = recover_credentials(
|
||||
BedrockAuthMethod::Profile,
|
||||
manual,
|
||||
|| futures::future::ready(Err("profile needs fresh credentials".to_owned())).boxed(),
|
||||
|| panic!("A failed profile reload must not execute login commands"),
|
||||
).await;
|
||||
assert_eq!(result, Err("profile needs fresh credentials".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::Result;
|
||||
use aws_config::BehaviorVersion;
|
||||
@@ -27,6 +28,7 @@ fn strip_context_marker(model_id: &str) -> String {
|
||||
pub struct BedrockClient {
|
||||
runtime_client: BedrockRuntimeClient,
|
||||
region: String,
|
||||
invocation_model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -37,40 +39,18 @@ pub struct BedrockClientConfig {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub session_token: Option<String>,
|
||||
pub credentials_expiry: Option<SystemTime>,
|
||||
pub invocation_model_id: Option<String>,
|
||||
pub cross_region_inference: bool,
|
||||
}
|
||||
|
||||
impl BedrockClientConfig {
|
||||
/// Applies external config (from Claude Code / OpenCode) as fallback values
|
||||
/// when Galaxy's own settings are at their defaults.
|
||||
pub fn with_external_fallbacks(mut self) -> Self {
|
||||
let external = ExternalBedrockConfig::load();
|
||||
if external.is_empty() {
|
||||
return self;
|
||||
}
|
||||
|
||||
if self.profile == "default" {
|
||||
if let Some(profile) = external.profile {
|
||||
log::info!("[bedrock] Using profile from external config: {profile}");
|
||||
self.profile = profile;
|
||||
}
|
||||
}
|
||||
|
||||
if self.region.is_empty() {
|
||||
if let Some(region) = external.region {
|
||||
log::info!("[bedrock] Using region from external config: {region}");
|
||||
self.region = region;
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BedrockError {
|
||||
#[error("Bedrock credentials not configured")]
|
||||
CredentialsNotConfigured,
|
||||
#[error("Failed to load AWS credentials: {0}")]
|
||||
CredentialsLoadFailed(String),
|
||||
#[error("Bedrock region not configured and could not be auto-detected")]
|
||||
RegionNotConfigured,
|
||||
#[error("Bedrock API error: {0}")]
|
||||
@@ -115,12 +95,20 @@ impl BedrockClient {
|
||||
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
|
||||
return Err(BedrockError::CredentialsNotConfigured);
|
||||
}
|
||||
if !super::authentication::credentials_are_current(
|
||||
config.credentials_expiry,
|
||||
SystemTime::now(),
|
||||
) {
|
||||
return Err(BedrockError::CredentialsLoadFailed(
|
||||
"AWS credentials have expired".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let creds = aws_credential_types::Credentials::new(
|
||||
&config.access_key_id,
|
||||
&config.secret_access_key,
|
||||
config.session_token,
|
||||
None,
|
||||
config.credentials_expiry,
|
||||
"warp-bedrock-static",
|
||||
);
|
||||
|
||||
@@ -148,11 +136,15 @@ impl BedrockClient {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[bedrock] Failed to resolve AWS credentials from provider: {e:?}");
|
||||
let message = crate::ai::aws_credentials::user_facing_aws_credentials_error_message(
|
||||
&e,
|
||||
&config.profile,
|
||||
);
|
||||
return Err(BedrockError::CredentialsLoadFailed(message));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("[bedrock] No credentials provider found in resolved AWS config");
|
||||
return Err(BedrockError::CredentialsNotConfigured);
|
||||
}
|
||||
|
||||
let region = aws_config
|
||||
@@ -165,6 +157,7 @@ impl BedrockClient {
|
||||
Ok(Self {
|
||||
runtime_client,
|
||||
region,
|
||||
invocation_model_id: config.invocation_model_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,14 +188,16 @@ impl BedrockClient {
|
||||
tool_result_archive: Vec<ConversationMessage>,
|
||||
) -> Result<LegacyResponseStream, BedrockError> {
|
||||
let base_model_id = strip_context_marker(model_id);
|
||||
let effective_model_id = if cross_region_inference {
|
||||
let effective_model_id = if let Some(model) = &self.invocation_model_id {
|
||||
model.clone()
|
||||
} else if cross_region_inference {
|
||||
apply_cross_region_prefix(&base_model_id, &self.region)
|
||||
} else {
|
||||
base_model_id
|
||||
};
|
||||
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
let caching_config = CachingConfig::from_external_config(&external_config);
|
||||
let caching_config = CachingConfig::from_external_config(&external_config).for_model(model_id);
|
||||
|
||||
if !caching_config.enabled {
|
||||
log::info!("[bedrock] Prompt caching disabled (DISABLE_PROMPT_CACHING=1)");
|
||||
|
||||
@@ -38,6 +38,22 @@ impl CachingConfig {
|
||||
extended_ttl_requested: external.enable_prompt_caching_1h,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_model(mut self, model_id: &str) -> Self {
|
||||
let model = galaxy_bedrock_model_catalog::model_metadata(model_id);
|
||||
let known_claude_4 = model.is_some_and(|metadata| {
|
||||
metadata.provider_name == "Anthropic"
|
||||
&& metadata.supports_agent_runtime() == Some(true)
|
||||
&& (metadata.model_id.starts_with("anthropic.claude-opus-4")
|
||||
|| metadata.model_id.starts_with("anthropic.claude-sonnet-4")
|
||||
|| metadata.model_id.starts_with("anthropic.claude-haiku-4"))
|
||||
});
|
||||
self.enabled &= known_claude_4;
|
||||
self.extended_ttl_requested &= self.enabled && model.is_some_and(|metadata| {
|
||||
["4-5", "4-6", "4-7", "4-8"].iter().any(|version| metadata.model_id.contains(version))
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConvertedRequest {
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
#[test]
|
||||
fn cache_preferences_do_not_enable_unsupported_or_unknown_model_caching() {
|
||||
let external = super::external_config::ExternalBedrockConfig {
|
||||
enable_prompt_caching_1h: true,
|
||||
..Default::default()
|
||||
};
|
||||
for id in ["future.new-model-v100", "openai.gpt-5.6-sol"] {
|
||||
let config = super::convert::CachingConfig::from_external_config(&external).for_model(id);
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.extended_ttl_requested);
|
||||
}
|
||||
}
|
||||
use aws_sdk_bedrockruntime::types::{ContentBlock, ConversationRole, SystemContentBlock, Tool};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
//! AWS Bedrock control-plane discovery.
|
||||
//!
|
||||
//! The foundation-model catalog is only a candidate list. Every candidate is
|
||||
//! checked with `GetFoundationModelAvailability` before it is offered to the
|
||||
//! user or persisted in Galaxy settings.
|
||||
//! AWS supplies the model inventory and invocation routes. Bundled metadata
|
||||
//! enriches known capabilities but never determines inventory membership.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::future::Future;
|
||||
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_bedrock::operation::list_inference_profiles::ListInferenceProfilesOutput;
|
||||
use aws_sdk_bedrock::types::{
|
||||
FoundationModelSummary, InferenceProfileStatus, InferenceProfileSummary,
|
||||
InferenceProfileType,
|
||||
};
|
||||
use aws_sdk_bedrock::Client;
|
||||
use aws_sdk_bedrockruntime::config::Region;
|
||||
use futures::{stream, StreamExt};
|
||||
use galaxy_bedrock_model_catalog::models as catalog_models;
|
||||
use galaxy_bedrock_model_catalog::model_metadata;
|
||||
use serde_json::json;
|
||||
|
||||
use super::client::{BedrockClientConfig, BedrockError};
|
||||
use super::models::foundation_model_id;
|
||||
use crate::ai::remote_logging::{RemoteLogLevel, RemoteLogRecord};
|
||||
use crate::settings::ai::BedrockModelConfig;
|
||||
|
||||
@@ -29,9 +37,66 @@ pub(crate) async fn discover_available_models(
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let client = Client::new(&aws_config);
|
||||
// The bundled catalog defines candidates; AWS defines account access.
|
||||
// Never add arbitrary entries from ListFoundationModels to this catalog.
|
||||
let candidates = catalog_models().to_vec();
|
||||
let inventory = client.list_foundation_models().send().await
|
||||
.map_err(|error| format!("Unable to list Bedrock models: {}", sanitized_error(&error)))?;
|
||||
let mut foundation_models = inventory.model_summaries.unwrap_or_default();
|
||||
let profiles = if config.cross_region_inference {
|
||||
collect_profile_pages(|token| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client.list_inference_profiles()
|
||||
.set_next_token(token)
|
||||
.send().await
|
||||
.map_err(|error| format!("Unable to list Bedrock inference profiles: {}", sanitized_error(&error)))
|
||||
}
|
||||
}).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut known_ids: HashSet<String> = foundation_models.iter()
|
||||
.map(|model| model.model_id().to_owned()).collect();
|
||||
// A profile can introduce a model before it appears in the regional listing.
|
||||
for profile in profiles.iter().filter(|profile| profile.status() == &InferenceProfileStatus::Active) {
|
||||
for model in profile.models() {
|
||||
let Some(arn) = model.model_arn() else { continue };
|
||||
if !arn.contains(":foundation-model/") {
|
||||
continue;
|
||||
}
|
||||
let id = foundation_model_id(arn);
|
||||
if !known_ids.insert(id.to_owned()) {
|
||||
continue;
|
||||
}
|
||||
let details = match client.get_foundation_model().model_identifier(id).send().await {
|
||||
Ok(output) => match output.model_details {
|
||||
Some(details) => details,
|
||||
None => {
|
||||
log::warn!("[bedrock] AWS returned no metadata for profile model {id}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
let error = sanitized_error(&error);
|
||||
log::warn!("[bedrock] Unable to describe profile model {id}: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let summary = FoundationModelSummary::builder()
|
||||
.model_id(details.model_id)
|
||||
.model_arn(details.model_arn)
|
||||
.set_model_name(details.model_name)
|
||||
.set_provider_name(details.provider_name)
|
||||
.set_input_modalities(details.input_modalities)
|
||||
.set_output_modalities(details.output_modalities)
|
||||
.set_response_streaming_supported(details.response_streaming_supported)
|
||||
.set_inference_types_supported(details.inference_types_supported)
|
||||
.set_model_lifecycle(details.model_lifecycle)
|
||||
.build().map_err(|error| format!("Invalid Bedrock model metadata: {error}"))?;
|
||||
foundation_models.push(summary);
|
||||
}
|
||||
}
|
||||
let inventory_count = foundation_models.len();
|
||||
let candidates = models_from_inventory(foundation_models, &profiles, config.cross_region_inference);
|
||||
let candidate_count = candidates.len();
|
||||
let sweep_id = uuid::Uuid::new_v4().to_string();
|
||||
let region = aws_config
|
||||
.region()
|
||||
@@ -39,30 +104,30 @@ pub(crate) async fn discover_available_models(
|
||||
.unwrap_or("UNKNOWN")
|
||||
.to_owned();
|
||||
log::info!(
|
||||
"[bedrock] Starting availability sweep {sweep_id}: {} catalog models, region={region}",
|
||||
"[bedrock] Starting availability sweep {sweep_id}: {} live candidates, region={region}",
|
||||
candidates.len()
|
||||
);
|
||||
let checks = candidates.into_iter().map(|metadata| {
|
||||
let checks = candidates.into_iter().map(|mut model| {
|
||||
let client = client.clone();
|
||||
let sweep_id = sweep_id.clone();
|
||||
let region = region.clone();
|
||||
async move {
|
||||
let model_id = metadata.model_id.as_str();
|
||||
let model_id = model.model_id.clone();
|
||||
let availability = match client
|
||||
.get_foundation_model_availability()
|
||||
.model_id(model_id)
|
||||
.model_id(&model_id)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(availability) => availability,
|
||||
Err(error) => {
|
||||
let error = crate::ai::remote_logging::sanitize_error(format!("{error:?}"));
|
||||
let error = sanitized_error(&error);
|
||||
log::warn!("[bedrock] Availability sweep {sweep_id}: model={model_id}, region={region}, decision=excluded, error={error}");
|
||||
return (None, RemoteLogRecord {
|
||||
level: RemoteLogLevel::Warn,
|
||||
message: "Bedrock model availability check failed".to_owned(),
|
||||
context: json!({"event": "bedrock_model_availability", "sweep_id": sweep_id, "model_id": model_id, "aws_region": region, "decision": "excluded", "error": error}),
|
||||
});
|
||||
}, false);
|
||||
}
|
||||
};
|
||||
let agreement = availability.agreement_availability().map(|value| value.status().as_str());
|
||||
@@ -70,7 +135,7 @@ pub(crate) async fn discover_available_models(
|
||||
let entitlement = availability.entitlement_availability().as_str();
|
||||
let region_status = availability.region_availability().as_str();
|
||||
let access_allowed = model_availability_is_usable(agreement, authorization, entitlement, region_status);
|
||||
let runtime_supported = metadata.supports_agent_runtime() != Some(false);
|
||||
let runtime_supported = model.runtime_supported != Some(false);
|
||||
let allowed = access_allowed && runtime_supported;
|
||||
let decision = if allowed { "allowed" } else { "excluded" };
|
||||
log::info!("[bedrock] Availability sweep {sweep_id}: model={model_id}, agreement={}, authorization={authorization}, entitlement={entitlement}, region_status={region_status}, runtime_supported={runtime_supported}, decision={decision}", agreement.unwrap_or("MISSING"));
|
||||
@@ -79,23 +144,20 @@ pub(crate) async fn discover_available_models(
|
||||
message: "Bedrock model availability checked".to_owned(),
|
||||
context: json!({"event": "bedrock_model_availability", "sweep_id": sweep_id, "model_id": model_id, "aws_region": region, "agreement": agreement, "authorization": authorization, "entitlement": entitlement, "region_availability": region_status, "agreement_error": availability.agreement_availability().and_then(|value| value.error_message()).map(crate::ai::remote_logging::sanitize_error), "access_allowed": access_allowed, "runtime_supported": runtime_supported, "decision": decision}),
|
||||
};
|
||||
if !allowed { return (None, record); }
|
||||
|
||||
(Some(BedrockModelConfig {
|
||||
model_id: model_id.to_owned(),
|
||||
display_name: metadata.model_name.clone(),
|
||||
vision_supported: metadata.supports_vision_input(),
|
||||
enabled: true,
|
||||
access_verified: true,
|
||||
}), record)
|
||||
model.access_verified = allowed;
|
||||
(allowed.then_some(model), record, true)
|
||||
}
|
||||
});
|
||||
let results = stream::iter(checks)
|
||||
.buffer_unordered(AVAILABILITY_CHECK_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
if candidate_count > 0 && !results.iter().any(|(_, _, checked)| *checked) {
|
||||
return Err("All Bedrock availability checks failed; saved model preferences are unchanged".to_owned());
|
||||
}
|
||||
|
||||
let (models, mut records): (Vec<_>, Vec<_>) = results.into_iter().unzip();
|
||||
let (models, mut records): (Vec<_>, Vec<_>) = results.into_iter()
|
||||
.map(|(model, record, _)| (model, record)).unzip();
|
||||
let mut models = models.into_iter().flatten().collect::<Vec<_>>();
|
||||
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
|
||||
log::info!(
|
||||
@@ -106,11 +168,84 @@ pub(crate) async fn discover_available_models(
|
||||
records.push(RemoteLogRecord {
|
||||
level: RemoteLogLevel::Info,
|
||||
message: "Bedrock availability sweep completed".to_owned(),
|
||||
context: json!({"event": "bedrock_availability_sweep_completed", "sweep_id": sweep_id, "aws_region": region, "catalog_count": catalog_models().len(), "checked_count": records.len(), "allowed_count": models.len()}),
|
||||
context: json!({"event": "bedrock_availability_sweep_completed", "sweep_id": sweep_id, "aws_region": region, "inventory_count": inventory_count, "candidate_count": candidate_count, "checked_count": records.len(), "allowed_count": models.len()}),
|
||||
});
|
||||
Ok(BedrockDiscovery { models, records })
|
||||
}
|
||||
|
||||
fn sanitized_error(error: &impl std::fmt::Debug) -> String {
|
||||
crate::ai::remote_logging::sanitize_error(format!("{error:?}"))
|
||||
}
|
||||
|
||||
async fn collect_profile_pages<F, Fut>(mut fetch: F) -> Result<Vec<InferenceProfileSummary>, String>
|
||||
where
|
||||
F: FnMut(Option<String>) -> Fut,
|
||||
Fut: Future<Output = Result<ListInferenceProfilesOutput, String>>,
|
||||
{
|
||||
let mut profiles = Vec::new();
|
||||
let mut token = None;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let page = fetch(token).await?;
|
||||
profiles.extend(page.inference_profile_summaries.unwrap_or_default());
|
||||
token = page.next_token.filter(|token| !token.is_empty());
|
||||
let Some(next) = &token else { return Ok(profiles) };
|
||||
if !seen.insert(next.clone()) {
|
||||
return Err("AWS repeated an inference-profile pagination token".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn models_from_inventory(
|
||||
foundation_models: Vec<FoundationModelSummary>,
|
||||
profiles: &[InferenceProfileSummary],
|
||||
cross_region: bool,
|
||||
) -> Vec<BedrockModelConfig> {
|
||||
let mut routes = profiles.iter()
|
||||
.filter(|profile| profile.status() == &InferenceProfileStatus::Active)
|
||||
.collect::<Vec<_>>();
|
||||
routes.sort_by_key(|profile| (
|
||||
profile.r#type() != &InferenceProfileType::SystemDefined,
|
||||
profile.inference_profile_id().starts_with("global."),
|
||||
profile.inference_profile_id().to_owned(),
|
||||
));
|
||||
let mut models = BTreeMap::new();
|
||||
for summary in foundation_models {
|
||||
let id = summary.model_id();
|
||||
let metadata = model_metadata(id);
|
||||
let text_output = summary.output_modalities().iter().any(|modality| modality.as_str() == "TEXT");
|
||||
let incompatible = (!summary.output_modalities().is_empty() && !text_output)
|
||||
|| summary.response_streaming_supported() == Some(false)
|
||||
|| metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false));
|
||||
if incompatible {
|
||||
continue;
|
||||
}
|
||||
let profile = cross_region.then(|| routes.iter().find(|profile| {
|
||||
!profile.models().is_empty() && profile.models().iter().all(|model| {
|
||||
model.model_arn().is_some_and(|arn| foundation_model_id(arn) == id)
|
||||
})
|
||||
})).flatten();
|
||||
let invocation = if let Some(profile) = profile {
|
||||
profile.inference_profile_arn().to_owned()
|
||||
} else if summary.inference_types_supported().iter().any(|kind| kind.as_str() == "ON_DEMAND") {
|
||||
id.to_owned()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
models.entry(id.to_owned()).or_insert_with(|| BedrockModelConfig {
|
||||
model_id: id.to_owned(),
|
||||
invocation_model_id: invocation,
|
||||
display_name: summary.model_name().unwrap_or(id).to_owned(),
|
||||
provider_name: summary.provider_name().unwrap_or_default().to_owned(),
|
||||
vision_supported: summary.input_modalities().iter().any(|modality| modality.as_str() == "IMAGE"),
|
||||
response_streaming_supported: summary.response_streaming_supported(),
|
||||
runtime_supported: (text_output && summary.response_streaming_supported() == Some(true)).then_some(true),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
models.into_values().collect()
|
||||
}
|
||||
|
||||
fn model_availability_is_usable(
|
||||
agreement_status: Option<&str>,
|
||||
authorization_status: &str,
|
||||
@@ -133,6 +268,14 @@ pub(crate) fn preserve_model_toggles(
|
||||
.find(|previous| previous.model_id == model.model_id)
|
||||
{
|
||||
model.enabled = previous.enabled;
|
||||
model.display_name.clone_from(&previous.display_name);
|
||||
}
|
||||
}
|
||||
for previous in existing {
|
||||
if !discovered.iter().any(|model| model.model_id == previous.model_id) {
|
||||
let mut preserved = previous.clone();
|
||||
preserved.access_verified = false;
|
||||
discovered.push(preserved);
|
||||
}
|
||||
}
|
||||
discovered
|
||||
@@ -161,7 +304,7 @@ async fn load_aws_config(
|
||||
&config.access_key_id,
|
||||
&config.secret_access_key,
|
||||
config.session_token.clone(),
|
||||
None,
|
||||
config.credentials_expiry,
|
||||
"galaxy-bedrock-discovery",
|
||||
);
|
||||
let mut loader =
|
||||
|
||||
@@ -1,4 +1,143 @@
|
||||
use super::model_availability_is_usable;
|
||||
use aws_sdk_bedrock::operation::list_inference_profiles::ListInferenceProfilesOutput;
|
||||
use aws_sdk_bedrock::types::{
|
||||
FoundationModelSummary, InferenceProfileModel, InferenceProfileStatus,
|
||||
InferenceProfileSummary, InferenceProfileType, InferenceType, ModelModality,
|
||||
};
|
||||
|
||||
fn foundation(id: &str, on_demand: bool) -> FoundationModelSummary {
|
||||
FoundationModelSummary::builder()
|
||||
.model_id(id)
|
||||
.model_arn(format!("arn:aws:bedrock:us-east-1::foundation-model/{id}"))
|
||||
.model_name("New live model")
|
||||
.provider_name("New provider")
|
||||
.input_modalities(ModelModality::Text)
|
||||
.output_modalities(ModelModality::Text)
|
||||
.response_streaming_supported(true)
|
||||
.set_inference_types_supported(on_demand.then(|| vec![InferenceType::OnDemand]))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn inference_profile(id: &str, model: &str) -> InferenceProfileSummary {
|
||||
InferenceProfileSummary::builder()
|
||||
.inference_profile_id(id)
|
||||
.inference_profile_name(id)
|
||||
.inference_profile_arn(format!("arn:aws:bedrock:us-east-1:123456789012:inference-profile/{id}"))
|
||||
.status(InferenceProfileStatus::Active)
|
||||
.r#type(InferenceProfileType::SystemDefined)
|
||||
.models(InferenceProfileModel::builder()
|
||||
.model_arn(format!("arn:aws:bedrock:us-east-1::foundation-model/{model}"))
|
||||
.build())
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_live_model_does_not_need_a_bundled_catalog_entry() {
|
||||
let id = "future.new-model-v100";
|
||||
assert!(galaxy_bedrock_model_catalog::model_metadata(id).is_none());
|
||||
let profile = inference_profile("us.future.new-model-v100", id);
|
||||
let models = super::models_from_inventory(
|
||||
vec![foundation(id, false)], &[profile.clone()], true,
|
||||
);
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].model_id, id);
|
||||
assert_eq!(models[0].invocation_model_id, profile.inference_profile_arn());
|
||||
assert_eq!(models[0].response_streaming_supported, Some(true));
|
||||
assert_eq!(models[0].provider_name, "New provider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_region_disabled_requires_an_actual_on_demand_route() {
|
||||
let id = "future.profile-only";
|
||||
let profile = inference_profile("us.future.profile-only", id);
|
||||
assert!(super::models_from_inventory(
|
||||
vec![foundation(id, false)], &[profile.clone()], false,
|
||||
).is_empty());
|
||||
let models = super::models_from_inventory(
|
||||
vec![foundation(id, true)], &[profile], false,
|
||||
);
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].invocation_model_id, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_deduplicates_routes_and_rejects_known_incompatible_models() {
|
||||
let id = "future.new-model-v100";
|
||||
let profile = inference_profile("us.future.new-model-v100", id);
|
||||
let models = super::models_from_inventory(
|
||||
vec![foundation(id, false), foundation(id, false)],
|
||||
&[profile.clone(), profile],
|
||||
true,
|
||||
);
|
||||
assert_eq!(models.len(), 1);
|
||||
assert!(super::models_from_inventory(
|
||||
vec![foundation("openai.gpt-5.5", true)], &[], true,
|
||||
).is_empty());
|
||||
let mut not_streaming = foundation(id, true);
|
||||
not_streaming.response_streaming_supported = Some(false);
|
||||
assert!(super::models_from_inventory(vec![not_streaming], &[], true).is_empty());
|
||||
let mut image_model = foundation(id, true);
|
||||
image_model.output_modalities = Some(vec![ModelModality::Image]);
|
||||
assert!(super::models_from_inventory(vec![image_model], &[], true).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_profile_discovery_follows_every_page() {
|
||||
let mut calls = Vec::new();
|
||||
let profiles = super::collect_profile_pages(|token| {
|
||||
calls.push(token.clone());
|
||||
let page = match token.as_deref() {
|
||||
None => ListInferenceProfilesOutput::builder()
|
||||
.inference_profile_summaries(inference_profile("us.future.first", "future.first"))
|
||||
.next_token("page-2").build(),
|
||||
Some("page-2") => ListInferenceProfilesOutput::builder()
|
||||
.inference_profile_summaries(inference_profile("us.future.second", "future.second"))
|
||||
.build(),
|
||||
Some(token) => panic!("Unexpected token: {token}"),
|
||||
};
|
||||
std::future::ready(Ok(page))
|
||||
}).await.unwrap();
|
||||
assert_eq!(profiles.len(), 2);
|
||||
assert_eq!(calls, [None, Some("page-2".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_or_repeating_profile_pages_do_not_return_partial_success() {
|
||||
let result = super::collect_profile_pages(|_| {
|
||||
std::future::ready(Err("network unavailable".to_owned()))
|
||||
}).await;
|
||||
assert_eq!(result.unwrap_err(), "network unavailable");
|
||||
let result = super::collect_profile_pages(|_| {
|
||||
std::future::ready(Ok(ListInferenceProfilesOutput::builder().next_token("repeated").build()))
|
||||
}).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transiently_missing_models_keep_preferences_without_restoring_authorization() {
|
||||
let mut saved = crate::settings::BedrockModelConfig {
|
||||
enabled: false,
|
||||
access_verified: true,
|
||||
model_id: "future.existing".to_owned(),
|
||||
display_name: "My model name".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
let preserved = super::preserve_model_toggles(&[saved.clone()], Vec::new());
|
||||
assert_eq!(preserved.len(), 1);
|
||||
assert!(!preserved[0].enabled);
|
||||
assert!(!preserved[0].access_verified);
|
||||
saved.invocation_model_id = "old-route".to_owned();
|
||||
let mut discovered = saved.clone();
|
||||
discovered.enabled = true;
|
||||
discovered.display_name = "AWS name".to_owned();
|
||||
discovered.invocation_model_id = "new-route".to_owned();
|
||||
let preserved = super::preserve_model_toggles(&[saved], vec![discovered]);
|
||||
assert!(!preserved[0].enabled);
|
||||
assert_eq!(preserved[0].display_name, "My model name");
|
||||
assert_eq!(preserved[0].invocation_model_id, "new-route");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_access_is_not_authorization_and_refresh_preserves_disabled_models() {
|
||||
@@ -8,6 +147,7 @@ fn cached_access_is_not_authorization_and_refresh_preserves_disabled_models() {
|
||||
vision_supported: false,
|
||||
enabled: false,
|
||||
access_verified: true,
|
||||
..Default::default()
|
||||
};
|
||||
let saved = serde_json::to_value(&model).unwrap();
|
||||
assert!(saved.get("access_verified").is_none());
|
||||
|
||||
@@ -268,6 +268,8 @@ fn get_test_config() -> Option<BedrockClientConfig> {
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
session_token: None,
|
||||
credentials_expiry: None,
|
||||
invocation_model_id: None,
|
||||
cross_region_inference: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ fn parse_claude_code_model_map(
|
||||
model_id: arn,
|
||||
display_name,
|
||||
vision_supported: true,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -23,6 +23,8 @@ fn get_test_config() -> Option<BedrockClientConfig> {
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
session_token: None,
|
||||
credentials_expiry: None,
|
||||
invocation_model_id: None,
|
||||
cross_region_inference: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub enum ProviderConfig {
|
||||
// Provider-neutral module boundary; Bedrock-specific transport helpers live here without a
|
||||
// provider-named directory so the provider layer can be reorganized independently of callers.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod authentication;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod client;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod convert;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
pub(crate) fn foundation_model_id(model_id: &str) -> &str {
|
||||
let id = model_id.rsplit('/').next().unwrap_or(model_id);
|
||||
let id = id.strip_suffix("[1m]").or_else(|| id.strip_suffix("[1M]")).unwrap_or(id);
|
||||
["global.", "us.", "eu.", "apac.", "jp.", "au."]
|
||||
.iter()
|
||||
.find_map(|prefix| id.strip_prefix(prefix))
|
||||
.unwrap_or(id)
|
||||
}
|
||||
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
|
||||
if model_id.starts_with("arn:") {
|
||||
return model_id.to_string();
|
||||
|
||||
@@ -1714,6 +1714,8 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
|
||||
"ask_user_question" => has(ToolType::AskUserQuestion),
|
||||
"read_skill" => has(ToolType::ReadSkill),
|
||||
"fetch_conversation" => has(ToolType::FetchConversation),
|
||||
"request_computer_use" => has(ToolType::RequestComputerUse),
|
||||
"use_computer" => has(ToolType::UseComputer),
|
||||
// This tool is implemented entirely inside the direct-provider response
|
||||
// translator, so it does not need a client ToolType capability bit.
|
||||
"recall_tool_history" => true,
|
||||
@@ -1730,8 +1732,77 @@ pub(crate) const IMPLEMENTATION_PROGRESS_GUIDANCE: &str = "## Incremental Implem
|
||||
|
||||
pub(crate) const SHELL_EXECUTION_GUIDANCE: &str = "## Shell Execution\nCommands use a persistent terminal. Never exit, log out of, replace with exec, or kill that shell or Galaxy. Do not append `exit $status` to a command to report its result; let the command's exit status be returned naturally. If a script needs exit, run it in an explicit child shell such as `sh -c '...'`. Stop a running agent command with interrupt_shell_command, not broad kill/pkill commands.\nRun builds, tests, installs, and workspace checks as separate shell tool calls so each has its own output and exit status. Keep stdout and stderr live: do not hide them in subprocess capture_output/PIPE, redirect everything to a file, or buffer a whole validation suite and print only its final tail. If a log file is needed, stream through tee while preserving the original command's exit status. Use non-interactive, non-watch modes and disable pagers. Give intentionally quiet commands a descriptive tool title so the user knows what is running; silence alone is not evidence of a hang. If repeated snapshots show no progress, investigate whether the command is waiting for input, watching, or buffering; do not blindly poll forever or start duplicate commands. Interrupt and correct an unintended watcher or blocked command before retrying.\n\n";
|
||||
|
||||
fn computer_target_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["screen", "window"], "default": "screen" },
|
||||
"window_id": { "type": "integer", "minimum": 1 },
|
||||
"pid": { "type": "integer", "minimum": 1 },
|
||||
},
|
||||
"required": ["type"],
|
||||
"description": "Omit to target the main screen. Use window only with a window_id and pid from the latest returned windows list; requires background computer use support.",
|
||||
})
|
||||
}
|
||||
|
||||
fn computer_screenshot_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_long_edge_px": { "type": "integer", "minimum": 1, "maximum": 4096, "default": 1568 },
|
||||
"max_total_px": { "type": "integer", "minimum": 1, "maximum": 4194304, "default": 1150000 },
|
||||
"target": computer_target_schema(),
|
||||
},
|
||||
"description": "Screenshot size limits and target. Omit for a main-screen screenshot with bounded image size.",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
ToolDefinition {
|
||||
name: "request_computer_use".to_string(),
|
||||
description: "Request desktop computer use on the machine running Galaxy and capture its initial screenshot. This uses the user's local desktop even when the terminal is connected by SSH. Call before interacting with the desktop; subsequent turns use the configured computer-use model and use_computer tool. Existing profile permissions apply. Screen Recording permission is needed to capture the screen; Accessibility permission is needed to control input on macOS.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_summary": { "type": "string", "description": "Specific desktop task and application to operate." },
|
||||
"screenshot_params": computer_screenshot_schema(),
|
||||
},
|
||||
"required": ["task_summary"],
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "use_computer".to_string(),
|
||||
description: "Perform a bounded batch of desktop actions after request_computer_use succeeds, then receive a screenshot. Use an empty actions array to inspect the desktop. Coordinates use original physical screenshot pixels, so scale coordinates from the displayed image using the returned original_width/width and original_height/height. Screen targets use main-display coordinates; window targets use coordinates relative to the exact enumerated window. Prefer small action batches and inspect the result. Named keys use OS keycodes; key accepts a single character. Button/key down and up are separate actions.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action_summary": { "type": "string" },
|
||||
"actions": {
|
||||
"type": "array", "maxItems": 20,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["wait", "mouse_move", "mouse_down", "mouse_up", "mouse_wheel", "type_text", "key_down", "key_up"] },
|
||||
"x": { "type": "integer" }, "y": { "type": "integer" },
|
||||
"button": { "type": "string", "enum": ["left", "right", "middle", "back", "forward"] },
|
||||
"text": { "type": "string", "maxLength": 100000 },
|
||||
"key": { "type": "string", "minLength": 1, "maxLength": 1 },
|
||||
"keycode": { "type": "integer", "description": "Platform keycode for modifier/navigation keys, such as macOS Return 36, Escape 53, Command 55, Shift 56, Option 58, Control 59." },
|
||||
"seconds": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"direction": { "type": "string", "enum": ["up", "down", "left", "right"] },
|
||||
"pixels": { "type": "integer", "minimum": 0 },
|
||||
"target": computer_target_schema(),
|
||||
},
|
||||
"required": ["type"],
|
||||
},
|
||||
},
|
||||
"take_screenshot": { "type": "boolean", "default": true },
|
||||
"screenshot_params": computer_screenshot_schema(),
|
||||
},
|
||||
"required": ["action_summary", "actions"],
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "run_shell_command".to_string(),
|
||||
description: "Execute a shell command in the user's terminal and return its output. Use for builds, tests, git, package managers, and other shell work. Commands run in this conversation's persistent shell and environment, starting in the supplied working directory. Successful cd commands affect subsequent calls in this shell; subshell directory changes do not. Avoid prefixing commands with cd to the directory you are already in, and use pwd only when the current location is uncertain. Never exit, log out of, replace, or kill this persistent shell. Run each build/test/install separately and keep its output live; avoid buffered subprocess wrappers and redirecting all output to files. Set wait_until_complete=false for commands that might run longer than a few seconds; Galaxy will return a command_id that can be monitored with read_shell_command_output.".to_string(),
|
||||
|
||||
@@ -839,6 +839,14 @@ pub fn build_tool_call_message(
|
||||
serde_json::from_str(tool_input_json).unwrap_or(serde_json::json!({}));
|
||||
|
||||
let tool = match tool_name {
|
||||
"request_computer_use" | "use_computer" => {
|
||||
crate::ai::runtime::action_from_tool_call(
|
||||
task_id,
|
||||
&galaxy_agent_core::ToolCall { id: tool_use_id.to_string(), name: tool_name.to_string(), arguments: input.clone() },
|
||||
&ai::skills::SkillPathOrigin::Local,
|
||||
&std::collections::HashMap::new(),
|
||||
).ok().and_then(|action| crate::ai::agent::provider_history::computer_use_tool(&action.action))
|
||||
}
|
||||
"run_shell_command" => {
|
||||
let command = input
|
||||
.get("command")
|
||||
@@ -1596,6 +1604,8 @@ fn run_agents_harness_from_str(harness_type: &str) -> Option<api::Harness> {
|
||||
|
||||
/// Built-in tools that Galaxy knows how to execute directly.
|
||||
const KNOWN_TOOLS: &[&str] = &[
|
||||
"request_computer_use",
|
||||
"use_computer",
|
||||
"run_shell_command",
|
||||
"read_files",
|
||||
"apply_file_diffs",
|
||||
@@ -1625,6 +1635,7 @@ const KNOWN_TOOLS: &[&str] = &[
|
||||
"read_skill",
|
||||
"fetch_conversation",
|
||||
"recall_tool_history",
|
||||
"update_task_list",
|
||||
];
|
||||
|
||||
pub(super) fn is_known_tool(name: &str) -> bool {
|
||||
|
||||
@@ -2,13 +2,14 @@ use std::collections::HashMap;
|
||||
|
||||
use galaxy_agent_core::{
|
||||
AgentEvent, ProviderRunOutcome, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities,
|
||||
StopReason, Usage,
|
||||
StopReason, TaskListSnapshot, Usage,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
|
||||
use super::provider_run_coordinator::ProviderRunProjection;
|
||||
use super::task_progress::{explicit_task_updates, task_list_from_plan_batch, todo_operations};
|
||||
use crate::ai::agent::runtime_activity;
|
||||
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
|
||||
use crate::ai::provider::response_translator::{
|
||||
@@ -54,6 +55,8 @@ pub(crate) struct ProviderRunResponseProjector {
|
||||
translator: RuntimeResponseTranslator,
|
||||
has_started_model_turn: bool,
|
||||
plan_tasks_projected: bool,
|
||||
task_list: Option<TaskListSnapshot>,
|
||||
pending_task_list: Option<TaskListSnapshot>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
@@ -63,6 +66,8 @@ impl ProviderRunResponseProjector {
|
||||
translator: RuntimeResponseTranslator::new(config),
|
||||
has_started_model_turn: false,
|
||||
plan_tasks_projected: false,
|
||||
task_list: None,
|
||||
pending_task_list: None,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
@@ -75,10 +80,45 @@ impl ProviderRunResponseProjector {
|
||||
translator: RuntimeResponseTranslator::restored(config, projection_was_initialized),
|
||||
has_started_model_turn: false,
|
||||
plan_tasks_projected: true,
|
||||
task_list: None,
|
||||
pending_task_list: None,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_task_list(mut self, task_list: Option<TaskListSnapshot>) -> Self {
|
||||
self.plan_tasks_projected |= task_list.is_some();
|
||||
self.task_list = task_list;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_restored_task_lists(
|
||||
mut self,
|
||||
visible: Option<TaskListSnapshot>,
|
||||
desired: Option<TaskListSnapshot>,
|
||||
) -> Self {
|
||||
self.plan_tasks_projected = visible.is_some() || desired.is_some();
|
||||
self.task_list = visible;
|
||||
self.pending_task_list =
|
||||
desired.filter(|snapshot| self.task_list.as_ref() != Some(snapshot));
|
||||
self
|
||||
}
|
||||
|
||||
fn reconcile_restored_tasks(&mut self) -> Vec<ResponseEvent> {
|
||||
let Some(snapshot) = self.pending_task_list.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
self.translator.initialize(&mut events);
|
||||
events.extend(
|
||||
todo_operations(self.task_list.as_ref(), &snapshot)
|
||||
.into_iter()
|
||||
.map(|operation| build_todo_update(&self.translator.config.task_id, operation)),
|
||||
);
|
||||
self.task_list = Some(snapshot);
|
||||
events
|
||||
}
|
||||
|
||||
pub(crate) fn project(
|
||||
&mut self,
|
||||
projection: ProviderRunProjection,
|
||||
@@ -86,7 +126,8 @@ impl ProviderRunResponseProjector {
|
||||
if self.finished {
|
||||
return Err("provider run projection is already finished".to_string());
|
||||
}
|
||||
match projection {
|
||||
let mut events = self.reconcile_restored_tasks();
|
||||
let projected = match projection {
|
||||
ProviderRunProjection::ModelTurnStarted { .. } => {
|
||||
if self.has_started_model_turn {
|
||||
self.translator.begin_followup_turn();
|
||||
@@ -111,23 +152,28 @@ impl ProviderRunResponseProjector {
|
||||
}
|
||||
ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()),
|
||||
ProviderRunProjection::ToolBatchReady { batch } => {
|
||||
if !self.plan_tasks_projected {
|
||||
if let Some(todos) = todos_from_plan_batch(&batch) {
|
||||
self.plan_tasks_projected = true;
|
||||
return Ok(vec![build_todo_update(
|
||||
&self.translator.config.task_id,
|
||||
api::message::update_todos::Operation::CreateTodoList(
|
||||
api::CreateTodoList {
|
||||
initial_todos: todos,
|
||||
},
|
||||
),
|
||||
)]);
|
||||
}
|
||||
let mut snapshots = explicit_task_updates(&batch);
|
||||
if snapshots.is_empty() && !self.plan_tasks_projected {
|
||||
snapshots.extend(task_list_from_plan_batch(&batch));
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for snapshot in snapshots {
|
||||
events.extend(
|
||||
todo_operations(self.task_list.as_ref(), &snapshot)
|
||||
.into_iter()
|
||||
.map(|operation| {
|
||||
build_todo_update(&self.translator.config.task_id, operation)
|
||||
}),
|
||||
);
|
||||
self.task_list = Some(snapshot);
|
||||
self.plan_tasks_projected = true;
|
||||
}
|
||||
// Tool batches indicate runtime activity, not completion of planned work.
|
||||
Ok(Vec::new())
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
}?;
|
||||
events.extend(projected);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
|
||||
@@ -143,19 +189,22 @@ impl ProviderRunResponseProjector {
|
||||
return Err("provider run projection is already finished".to_string());
|
||||
}
|
||||
self.finished = true;
|
||||
match outcome {
|
||||
ProviderRunOutcome::Completed(completion) => Ok(self
|
||||
let mut events = self.reconcile_restored_tasks();
|
||||
let terminal = match outcome {
|
||||
ProviderRunOutcome::Completed(completion) => self
|
||||
.translator
|
||||
.finish_provider_run(completion.stop_reason.clone(), aggregate_usage)),
|
||||
ProviderRunOutcome::Failed(failure) => Ok(self.translator.provider_failure(
|
||||
.finish_provider_run(completion.stop_reason.clone(), aggregate_usage),
|
||||
ProviderRunOutcome::Failed(failure) => self.translator.provider_failure(
|
||||
&failure.message,
|
||||
failure.source.as_ref(),
|
||||
aggregate_usage,
|
||||
)),
|
||||
ProviderRunOutcome::Cancelled { .. } => Ok(self
|
||||
),
|
||||
ProviderRunOutcome::Cancelled { .. } => self
|
||||
.translator
|
||||
.finish_provider_run(StopReason::Cancelled, aggregate_usage)),
|
||||
}
|
||||
.finish_provider_run(StopReason::Cancelled, aggregate_usage),
|
||||
};
|
||||
events.extend(terminal);
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,42 +223,21 @@ fn authentication_provider_for_runtime(runtime_id: &str) -> api::LlmProvider {
|
||||
|
||||
// Only explicit multi-step checklists in a plan become tasks. Other document bullets
|
||||
// (risks, examples, requirements) are not executable tasks.
|
||||
#[cfg(test)]
|
||||
fn todos_from_plan_batch(
|
||||
batch: &galaxy_agent_core::PendingToolBatch,
|
||||
) -> Option<Vec<api::TodoItem>> {
|
||||
let plan_call = batch.calls.iter().find(|pending| {
|
||||
matches!(
|
||||
pending.call.name.as_str(),
|
||||
"create_plan" | "create_documents"
|
||||
)
|
||||
})?;
|
||||
let content = plan_call
|
||||
.call
|
||||
.arguments
|
||||
.get("documents")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("content")?
|
||||
.as_str()?;
|
||||
let mut lines = content.lines();
|
||||
lines.find(|line| matches!(line.trim(), "## Tasks" | "## Implementation Tasks"))?;
|
||||
let todos = lines
|
||||
.take_while(|line| !line.trim().starts_with("## "))
|
||||
.filter_map(|line| line.trim().strip_prefix("- [ ]").map(str::trim))
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(50)
|
||||
.enumerate()
|
||||
.map(|(index, item)| api::TodoItem {
|
||||
id: format!("plan-{}-{index}", plan_call.call.id),
|
||||
title: item
|
||||
.split_once(" - ")
|
||||
.map_or(item, |(title, _)| title)
|
||||
.trim()
|
||||
.to_owned(),
|
||||
description: item.to_owned(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(todos.len() > 1).then_some(todos)
|
||||
task_list_from_plan_batch(batch).map(|snapshot| {
|
||||
snapshot
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(|task| api::TodoItem {
|
||||
id: task.id,
|
||||
description: task.title.clone(),
|
||||
title: task.title,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_todo_update(
|
||||
|
||||
@@ -82,6 +82,65 @@ fn restored_provider_projection_skips_stream_initialization() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_task_progress_replay_does_not_duplicate_the_persisted_checklist() {
|
||||
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
|
||||
let snapshot = TaskListSnapshot { tasks: vec![
|
||||
TaskListItem { id: "inspect".to_owned(), title: "Inspect".to_owned(), status: TaskListStatus::Completed },
|
||||
TaskListItem { id: "verify".to_owned(), title: "Verify".to_owned(), status: TaskListStatus::InProgress },
|
||||
] };
|
||||
let mut projector = ProviderRunResponseProjector::restored(RuntimeResponseConfig {
|
||||
task_id: "task".to_owned(), conversation_id: "conversation".to_owned(), needs_create_task: false,
|
||||
user_query: None, model_id: "model".to_owned(), max_context_tokens: Some(1000),
|
||||
capabilities: RuntimeCapabilities::provider(), empty_output_message: None,
|
||||
}, true).with_task_list(Some(snapshot.clone()));
|
||||
let batch = galaxy_agent_core::PendingToolBatch {
|
||||
work_id: galaxy_agent_core::ExternalWorkId {
|
||||
run_id: galaxy_agent_core::ProviderRunId::new("run"), epoch: galaxy_agent_core::RunEpoch::new(1),
|
||||
},
|
||||
calls: vec![galaxy_agent_core::PendingToolCall {
|
||||
call: galaxy_agent_core::ToolCall {
|
||||
id: "progress".to_owned(), name: galaxy_agent_core::UPDATE_TASK_LIST_NAME.to_owned(),
|
||||
arguments: serde_json::to_value(&snapshot).unwrap(),
|
||||
},
|
||||
state: galaxy_agent_core::PendingToolCallState::Resolved { result: galaxy_agent_core::ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "progress".to_owned(), status: galaxy_agent_core::ToolResultStatus::Success,
|
||||
content: snapshot.to_tool_result_content(),
|
||||
}},
|
||||
}],
|
||||
};
|
||||
assert!(projector.project(ProviderRunProjection::ToolBatchReady { batch }).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_committed_task_snapshot_repairs_missing_projection_even_on_terminal_cleanup() {
|
||||
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
|
||||
let snapshot = TaskListSnapshot { tasks: vec![TaskListItem {
|
||||
id: "verify".to_owned(), title: "Verify".to_owned(), status: TaskListStatus::Completed,
|
||||
}] };
|
||||
let mut projector = ProviderRunResponseProjector::restored(RuntimeResponseConfig {
|
||||
task_id: "task".to_owned(), conversation_id: "conversation".to_owned(), needs_create_task: false,
|
||||
user_query: None, model_id: "model".to_owned(), max_context_tokens: Some(1000),
|
||||
capabilities: RuntimeCapabilities::provider(), empty_output_message: None,
|
||||
}, true).with_restored_task_lists(None, Some(snapshot));
|
||||
let events = projector.finish(&galaxy_agent_core::ProviderRunOutcome::Cancelled {
|
||||
reason: "restored cancellation".to_owned(),
|
||||
}, &Usage::default()).unwrap();
|
||||
let operations = events.into_iter().filter_map(|event| {
|
||||
let Some(response_event::Type::ClientActions(actions)) = event.r#type else { return None };
|
||||
Some(actions.actions.into_iter().filter_map(|action| {
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = action.action else { return None };
|
||||
Some(add.messages.into_iter().filter_map(|message| {
|
||||
let Some(message::Message::UpdateTodos(update)) = message.message else { return None };
|
||||
update.operation
|
||||
}).collect::<Vec<_>>())
|
||||
}).flatten().collect::<Vec<_>>())
|
||||
}).flatten().collect::<Vec<_>>();
|
||||
assert!(matches!(&operations[0], message::update_todos::Operation::CreateTodoList(_)));
|
||||
assert!(matches!(&operations[1], message::update_todos::Operation::MarkTodosCompleted(mark) if mark.todo_ids == ["verify"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_activity_does_not_create_or_complete_task_lists() {
|
||||
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
|
||||
|
||||
@@ -4,6 +4,8 @@ mod provider_run_coordinator;
|
||||
mod rig;
|
||||
mod rig_request;
|
||||
mod rig_tool;
|
||||
pub(crate) use rig_tool::action_from_tool_call;
|
||||
mod task_progress;
|
||||
|
||||
pub(crate) use child_context::add_parent_context;
|
||||
pub(crate) use event_translator::{
|
||||
@@ -19,3 +21,4 @@ pub(crate) use rig::{
|
||||
PreparedProviderRun, ProviderActionContext,
|
||||
};
|
||||
pub(crate) use rig_request::OrchestrationModelOption;
|
||||
pub(crate) use task_progress::{restored_task_list, task_list_from_api_tasks};
|
||||
|
||||
@@ -12,11 +12,12 @@ use galaxy_agent_core::{
|
||||
ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile,
|
||||
ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome,
|
||||
ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch,
|
||||
RuntimeKind, StopReason, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage,
|
||||
RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage,
|
||||
};
|
||||
use instant::Instant;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::model_output_logging;
|
||||
@@ -205,6 +206,7 @@ impl ProviderRunCoordinator {
|
||||
limits,
|
||||
);
|
||||
run.replace_tool_result_archive(tool_result_archive);
|
||||
run.set_task_list(initial_task_list(&base_profile.request))?;
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(BASE_PROVIDER_PROFILE.to_string(), base_profile);
|
||||
Self::new(run, profiles)
|
||||
@@ -251,6 +253,34 @@ impl ProviderRunCoordinator {
|
||||
self.profiles.get(profile).map(|profile| &profile.request)
|
||||
}
|
||||
|
||||
/// The pending model profile owns the context budget, including after a
|
||||
/// preflight overflow has returned the run to its compaction boundary.
|
||||
pub(crate) fn current_context_limit(&self) -> Option<u32> {
|
||||
self.profiles
|
||||
.get(self.run.profile().as_str())
|
||||
.and_then(|profile| profile.max_context_tokens)
|
||||
.or(self.max_context_tokens)
|
||||
}
|
||||
|
||||
/// Leave enough history space for a command-monitor turn before switching
|
||||
/// profiles, accounting for each profile's own fixed prompt and output budget.
|
||||
pub(crate) fn progressive_summary_context_budget(&self) -> Option<(u32, u32)> {
|
||||
[self.run.profile().as_str(), CLI_MONITOR_PROVIDER_PROFILE]
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let profile = self.profiles.get(name)?;
|
||||
let limit = profile.max_context_tokens.or(self.max_context_tokens)?;
|
||||
let mut request = profile.request.clone();
|
||||
request.prompt = None;
|
||||
request.messages.clear();
|
||||
add_task_list_context(&mut request, self.run.task_list());
|
||||
let fixed = u32::try_from(estimate_turn_request_tokens(&request))
|
||||
.unwrap_or(u32::MAX);
|
||||
Some((limit, fixed))
|
||||
})
|
||||
.min_by_key(|(limit, fixed)| limit.saturating_sub(*fixed))
|
||||
}
|
||||
|
||||
/// Includes the current transcript, fixed request context, tools, and output reservation.
|
||||
/// Read at the model boundary so newly committed tool output is included.
|
||||
pub(crate) fn estimated_context_tokens(&self) -> Option<(u32, u32)> {
|
||||
@@ -258,6 +288,7 @@ impl ProviderRunCoordinator {
|
||||
let mut request = profile.request.clone();
|
||||
request.prompt = None;
|
||||
request.messages.clear();
|
||||
add_task_list_context(&mut request, self.run.task_list());
|
||||
let persistent_tokens = estimate_turn_request_tokens(&request);
|
||||
request.messages = self.run.transcript().to_vec();
|
||||
Some((
|
||||
@@ -405,6 +436,9 @@ impl ProviderRunCoordinator {
|
||||
}
|
||||
}
|
||||
Some(ProviderRunStep::DispatchTools(batch)) => {
|
||||
if self.run.task_list().is_none() {
|
||||
self.run.set_task_list(task_list_from_plan_batch(&batch))?;
|
||||
}
|
||||
if !self.checkpoint_or_fail(&mut checkpoint).await? {
|
||||
return self.terminal_block();
|
||||
}
|
||||
@@ -473,8 +507,12 @@ impl ProviderRunCoordinator {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let request =
|
||||
request_for_model_call(profile.request.clone(), call, Some(max_context_tokens));
|
||||
let request = request_for_model_call(
|
||||
profile.request.clone(),
|
||||
call,
|
||||
Some(max_context_tokens),
|
||||
self.run.task_list(),
|
||||
);
|
||||
// Provider tokenizers and request envelopes differ. Compact before the
|
||||
// estimate reaches the advertised limit, leaving room for that uncertainty.
|
||||
let safe_context_tokens = u64::from(max_context_tokens) * 9 / 10;
|
||||
@@ -558,6 +596,7 @@ impl ProviderRunCoordinator {
|
||||
profile.request.clone(),
|
||||
&call,
|
||||
profile.max_context_tokens.or(self.max_context_tokens),
|
||||
self.run.task_list(),
|
||||
);
|
||||
let started_at = Instant::now();
|
||||
let (attempt_sender, attempt_control) = turn_control();
|
||||
@@ -1149,6 +1188,7 @@ fn request_for_model_call(
|
||||
mut template: TurnRequest,
|
||||
call: &ProviderModelCall,
|
||||
max_context_tokens: Option<u32>,
|
||||
task_list: Option<&TaskListSnapshot>,
|
||||
) -> TurnRequest {
|
||||
template.prompt = None;
|
||||
template.messages = call.messages.clone();
|
||||
@@ -1157,6 +1197,7 @@ fn request_for_model_call(
|
||||
message
|
||||
.truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens);
|
||||
}
|
||||
add_task_list_context(&mut template, task_list);
|
||||
add_exploration_reminder(&mut template);
|
||||
template
|
||||
}
|
||||
|
||||
@@ -461,6 +461,7 @@ async fn coordinator_awaiting_tools(
|
||||
fn tool_result(call_id: &str, content: &str, status: ToolResultStatus) -> ToolEvent {
|
||||
ToolEvent::Completed {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call_id.to_string(),
|
||||
content: content.to_string(),
|
||||
status,
|
||||
@@ -514,6 +515,7 @@ async fn one_run_drives_model_tool_and_followup_turns_with_atomic_history() {
|
||||
.complete_tool(
|
||||
&batch.work_id,
|
||||
ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "read-1".to_string(),
|
||||
content: "manifest contents".to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
@@ -945,6 +947,50 @@ async fn inline_tool_batches_commit_before_the_next_model_boundary() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_progress_reaches_the_next_model_after_compaction_and_restore() {
|
||||
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
|
||||
use crate::ai::runtime::task_progress::{add_task_list_context, initial_task_list, task_list_tool};
|
||||
|
||||
let initial = TaskListSnapshot { tasks: vec![TaskListItem {
|
||||
id: "verify".to_owned(), title: "Build the change".to_owned(), status: TaskListStatus::InProgress,
|
||||
}] };
|
||||
let current = TaskListSnapshot { tasks: vec![TaskListItem {
|
||||
status: TaskListStatus::Completed, ..initial.tasks[0].clone()
|
||||
}] };
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![
|
||||
tool_turn_with_calls(vec![ToolCall {
|
||||
id: "progress".to_owned(),
|
||||
name: galaxy_agent_core::UPDATE_TASK_LIST_NAME.to_owned(),
|
||||
arguments: serde_json::to_value(¤t).unwrap(),
|
||||
}]),
|
||||
answer_turn(),
|
||||
]));
|
||||
let mut template = request();
|
||||
template.tools.push(task_list_tool());
|
||||
add_task_list_context(&mut template, Some(&initial));
|
||||
let mut coordinator = ProviderRunCoordinator::from_request(
|
||||
"run", ProviderRunProfile::new(runtime.clone(), template.clone()), Vec::new(), ProviderRunLimits::default(),
|
||||
).unwrap();
|
||||
let (_sender, control) = turn_control();
|
||||
assert!(matches!(coordinator.drive_until_blocked(control, |_| Ok(())).await.unwrap(), ProviderRunBlock::ReadyToCallModel));
|
||||
assert_eq!(coordinator.run().task_list(), Some(¤t));
|
||||
let length = coordinator.run().transcript().len();
|
||||
coordinator.run_mut().compact_transcript_at_model_boundary(length, vec![ConversationMessage {
|
||||
role: MessageRole::User, content: MessageContent::Text("Prior work summarized.".to_owned()),
|
||||
}]).unwrap();
|
||||
coordinator.run_mut().replace_tool_result_archive(Vec::new());
|
||||
let restored: ProviderRun = serde_json::from_str(&serde_json::to_string(coordinator.run()).unwrap()).unwrap();
|
||||
restored.validate_restored_state().unwrap();
|
||||
let mut coordinator = ProviderRunCoordinator::new(restored, BTreeMap::from([
|
||||
(BASE_PROVIDER_PROFILE.to_owned(), ProviderRunProfile::new(runtime.clone(), template)),
|
||||
])).unwrap();
|
||||
let (_sender, control) = turn_control();
|
||||
coordinator.drive_until_blocked(control, |_| Ok(())).await.unwrap();
|
||||
assert_eq!(initial_task_list(&runtime.requests()[1]), Some(current));
|
||||
assert_eq!(runtime.requests()[1].messages.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recoverable_start_failure_retries_the_same_work_identity() {
|
||||
let mut recoverable = AgentError::new(AgentErrorKind::Transport, "temporary network error");
|
||||
@@ -1558,6 +1604,168 @@ fn context_estimate_tracks_live_transcript_and_reserves_fixed_context() {
|
||||
assert_eq!(fixed_before, fixed_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_boundary_keeps_the_monitor_context_limit() {
|
||||
let base_runtime = Arc::new(ScriptedRuntime::new(vec![]));
|
||||
let cli_runtime = Arc::new(ScriptedRuntime::new(vec![]));
|
||||
let run = ProviderRun::new(
|
||||
"monitor-compaction-budget",
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("history".repeat(5_000)),
|
||||
}],
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
ProviderRunLimits::default(),
|
||||
);
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
BASE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(base_runtime.clone(), request()),
|
||||
);
|
||||
profiles.insert(
|
||||
CLI_MONITOR_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(cli_runtime.clone(), request())
|
||||
.with_max_context_tokens(Some(8_000)),
|
||||
);
|
||||
let mut coordinator = ProviderRunCoordinator::new(run, profiles).unwrap();
|
||||
coordinator.set_max_context_tokens(Some(100_000));
|
||||
let (_sender, control) = turn_control();
|
||||
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(block, ProviderRunBlock::ContextWindowExceeded);
|
||||
assert_eq!(coordinator.run().state(), &ProviderRunState::ReadyToCallModel);
|
||||
assert_eq!(coordinator.current_context_limit(), Some(8_000));
|
||||
assert!(base_runtime.requests().is_empty());
|
||||
assert!(cli_runtime.requests().is_empty());
|
||||
|
||||
let work_id = coordinator.run().ready_work_id().unwrap();
|
||||
coordinator
|
||||
.run_mut()
|
||||
.continue_ready_with_observation(
|
||||
&work_id,
|
||||
MessageContent::Text("Command finished".to_string()),
|
||||
BASE_PROVIDER_PROFILE,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(coordinator.current_context_limit(), Some(100_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_budget_anticipates_switching_to_a_smaller_monitor() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![]));
|
||||
let mut coordinator = coordinator(runtime.clone());
|
||||
coordinator.set_max_context_tokens(Some(500_000));
|
||||
coordinator
|
||||
.insert_profile(
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
ProviderRunProfile::new(runtime, request()).with_max_context_tokens(Some(275_000)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(coordinator.current_context_limit(), Some(500_000));
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.progressive_summary_context_budget()
|
||||
.map(|(limit, _)| limit),
|
||||
Some(275_000)
|
||||
);
|
||||
|
||||
coordinator
|
||||
.run_mut()
|
||||
.set_ready_profile(CLI_MONITOR_PROVIDER_PROFILE)
|
||||
.unwrap();
|
||||
assert_eq!(coordinator.current_context_limit(), Some(275_000));
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.progressive_summary_context_budget()
|
||||
.map(|(limit, _)| limit),
|
||||
Some(275_000)
|
||||
);
|
||||
|
||||
coordinator
|
||||
.run_mut()
|
||||
.set_ready_profile(BASE_PROVIDER_PROFILE)
|
||||
.unwrap();
|
||||
assert_eq!(coordinator.current_context_limit(), Some(500_000));
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.progressive_summary_context_budget()
|
||||
.map(|(limit, _)| limit),
|
||||
Some(275_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_budget_respects_smaller_active_limits_and_profile_fallbacks() {
|
||||
for (base_limit, monitor_limit, fallback_limit, expected) in [
|
||||
(
|
||||
Some(200_000),
|
||||
Some(Some(275_000)),
|
||||
Some(500_000),
|
||||
Some(200_000),
|
||||
),
|
||||
(Some(500_000), Some(None), Some(275_000), Some(275_000)),
|
||||
(Some(500_000), None, Some(275_000), Some(500_000)),
|
||||
(None, None, None, None),
|
||||
] {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![]));
|
||||
let mut coordinator = ProviderRunCoordinator::from_request(
|
||||
"progressive-summary-budget",
|
||||
ProviderRunProfile::new(runtime.clone(), request()).with_max_context_tokens(base_limit),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
coordinator.set_max_context_tokens(fallback_limit);
|
||||
if let Some(monitor_limit) = monitor_limit {
|
||||
coordinator
|
||||
.insert_profile(
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
ProviderRunProfile::new(runtime, request())
|
||||
.with_max_context_tokens(monitor_limit),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
coordinator.current_context_limit(),
|
||||
base_limit.or(fallback_limit)
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.progressive_summary_context_budget()
|
||||
.map(|(limit, _)| limit),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_budget_accounts_for_output_reservations_when_selecting_a_profile() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![]));
|
||||
let mut coordinator = coordinator(runtime.clone());
|
||||
coordinator.set_max_context_tokens(Some(275_000));
|
||||
let mut monitor_request = request();
|
||||
monitor_request.max_output_tokens = Some(300_000);
|
||||
coordinator
|
||||
.insert_profile(
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
ProviderRunProfile::new(runtime, monitor_request)
|
||||
.with_max_context_tokens(Some(500_000)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (limit, persistent_tokens) = coordinator.progressive_summary_context_budget().unwrap();
|
||||
|
||||
assert_eq!(coordinator.current_context_limit(), Some(275_000));
|
||||
assert_eq!(limit, 500_000);
|
||||
assert!(persistent_tokens > 300_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monitor_request_uses_its_profile_context_limit() {
|
||||
let base_runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
|
||||
+21
-16
@@ -133,20 +133,25 @@ pub(crate) async fn prepare_provider_run(
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let mut cli_params = params.clone();
|
||||
let computer_use_model = params.computer_use_model.as_str();
|
||||
let computer_use_profile = if params.computer_use_enabled
|
||||
&& !computer_use_model.is_empty()
|
||||
&& computer_use_model != "computer-use-agent-auto"
|
||||
&& computer_use_model != params.model.as_str()
|
||||
&& !matches!(
|
||||
&computer_use_provider_config,
|
||||
crate::ai::provider::ProviderConfig::None
|
||||
) {
|
||||
let computer_use_profile = if supported_tools.contains(&ToolType::RequestComputerUse) {
|
||||
let mut computer_params = params.clone();
|
||||
computer_params.model = params.computer_use_model.clone();
|
||||
let computer_provider = if computer_use_model.is_empty()
|
||||
|| computer_use_model == "computer-use-agent-auto"
|
||||
|| computer_use_model == params.model.as_str()
|
||||
|| matches!(&computer_use_provider_config, crate::ai::provider::ProviderConfig::None)
|
||||
{
|
||||
base_provider_config.clone()
|
||||
} else {
|
||||
computer_params.model = params.computer_use_model.clone();
|
||||
computer_use_provider_config
|
||||
};
|
||||
let mut computer_tools = supported_tools.clone();
|
||||
computer_tools.retain(|tool| *tool != ToolType::RequestComputerUse);
|
||||
computer_tools.push(ToolType::UseComputer);
|
||||
let (runtime, prepared) = prepare_provider_profile(
|
||||
computer_use_provider_config,
|
||||
computer_provider,
|
||||
computer_params,
|
||||
supported_tools.clone(),
|
||||
computer_tools,
|
||||
supported_cli_agent_tools.clone(),
|
||||
None,
|
||||
)
|
||||
@@ -315,9 +320,8 @@ pub(crate) async fn provider_runtime_for_request(
|
||||
model,
|
||||
reasoning_effort: config.reasoning_effort,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
auth_file: None,
|
||||
},
|
||||
)),
|
||||
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver())),
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let credential = config.api_key.unwrap_or_default();
|
||||
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
|
||||
@@ -354,14 +358,15 @@ pub(crate) async fn provider_runtime_for_request(
|
||||
},
|
||||
crate::ai::provider::ProviderConfig::Bedrock(config) => {
|
||||
let max_output_tokens = Some(bedrock_max_output_tokens(&model));
|
||||
let cross_region_inference = config.cross_region_inference;
|
||||
let cross_region_inference = config.invocation_model_id.is_none() && config.cross_region_inference;
|
||||
let caching_config =
|
||||
CachingConfig::from_external_config(&ExternalBedrockConfig::load());
|
||||
CachingConfig::from_external_config(&ExternalBedrockConfig::load()).for_model(&model);
|
||||
let invocation_model = config.invocation_model_id.clone().unwrap_or(model);
|
||||
let client = BedrockClient::from_config(config).await?;
|
||||
Arc::new(BedrockRuntime::from_aws_client(
|
||||
client.runtime_client(),
|
||||
BedrockRigConfig {
|
||||
model,
|
||||
model: invocation_model,
|
||||
region: client.region().to_string(),
|
||||
cross_region_inference,
|
||||
prompt_caching: caching_config.enabled,
|
||||
|
||||
@@ -15,6 +15,9 @@ use uuid::Uuid;
|
||||
use warp_multi_agent_api as api;
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::task_progress::{
|
||||
add_task_list_context, task_list_from_api_tasks, task_list_tool, TASK_PROGRESS_GUIDANCE,
|
||||
};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode};
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
@@ -277,7 +280,9 @@ fn prepare_rig_turn_for_provider(
|
||||
// History recall cannot advance a running command. Keeping it in the CLI tool list lets
|
||||
// the model spend its monitor turn recalling a prior snapshot instead of scheduling
|
||||
// `read_shell_command_output`, so make polling the only inspection path here.
|
||||
tools.retain(|tool| tool.name != "recall_tool_history");
|
||||
tools.retain(|tool| {
|
||||
!matches!(tool.name.as_str(), "recall_tool_history" | "update_task_list")
|
||||
});
|
||||
}
|
||||
RigRequestMode::CompletedCommandAssessment => {
|
||||
// The caller deliberately disables tools for the final assessment. The inline history
|
||||
@@ -348,6 +353,8 @@ fn prepare_rig_turn_for_provider(
|
||||
request.system_prompt = Some(system_prompt);
|
||||
request.tools = tools;
|
||||
request.max_output_tokens = max_output_tokens;
|
||||
let task_list = task_list_from_api_tasks(&tasks, &task_id);
|
||||
add_task_list_context(&mut request, task_list.as_ref());
|
||||
|
||||
PreparedRigTurn {
|
||||
max_context_tokens,
|
||||
@@ -382,17 +389,7 @@ fn input_messages(
|
||||
) -> Vec<ConversationMessage> {
|
||||
let mut messages = Vec::new();
|
||||
if !tool_results.is_empty() {
|
||||
let mut parts = tool_results
|
||||
.into_iter()
|
||||
.map(|result| {
|
||||
let is_error = result.is_error();
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut parts = ToolResult::into_batch_content_parts(tool_results);
|
||||
let content = if parts.len() == 1 {
|
||||
let ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
@@ -692,6 +689,7 @@ fn tool_definitions(
|
||||
.into_iter()
|
||||
.filter(|tool| tool_name_is_supported(&tool.name, &supported))
|
||||
.collect::<Vec<_>>();
|
||||
tools.push(task_list_tool());
|
||||
let mut mcp_tool_aliases = HashMap::new();
|
||||
|
||||
if !supported.contains(&ToolType::CallMcpTool) {
|
||||
@@ -782,7 +780,9 @@ fn add_tool_title_parameter(schema: &mut serde_json::Value) -> String {
|
||||
}
|
||||
|
||||
fn tools_are_inline_only(tools: &[ToolDefinition]) -> bool {
|
||||
tools.iter().all(|tool| tool.name == "recall_tool_history")
|
||||
tools.iter().all(|tool| {
|
||||
matches!(tool.name.as_str(), "recall_tool_history" | "update_task_list")
|
||||
})
|
||||
}
|
||||
|
||||
const MAX_PROVIDER_TOOL_NAME_BYTES: usize = 64;
|
||||
@@ -850,6 +850,9 @@ fn build_system_prompt(
|
||||
);
|
||||
prompt.push_str(SHELL_EXECUTION_GUIDANCE);
|
||||
prompt.push_str(PROGRESS_COMMUNICATION_GUIDANCE);
|
||||
if tools.iter().any(|tool| tool.name == "update_task_list") {
|
||||
prompt.push_str(TASK_PROGRESS_GUIDANCE);
|
||||
}
|
||||
let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten();
|
||||
let mut environment = Vec::new();
|
||||
let mut request_time = None;
|
||||
@@ -1030,6 +1033,12 @@ fn build_system_prompt(
|
||||
{
|
||||
prompt.push_str(CODING_DELEGATION_GUIDANCE);
|
||||
}
|
||||
if tools.iter().any(|tool| tool.name == "request_computer_use") {
|
||||
prompt.push_str("## Desktop Computer Use\nUse request_computer_use for tasks that require seeing or interacting with the user's desktop. This controls the machine running Galaxy, including when the terminal is connected to another host. Request the specific authorized task and wait for the screenshot. A successful request switches to the configured computer-use model; rejected or failed requests do not grant control.\n\n");
|
||||
}
|
||||
if tools.iter().any(|tool| tool.name == "use_computer") {
|
||||
prompt.push_str("## Desktop Computer Use\nInspect the latest returned screenshot before acting. Use use_computer with small action batches, then inspect the new screenshot. Use original physical pixel coordinates: multiply displayed image x by original_width/width and y by original_height/height. Every button/key press requires both down and up actions. For window targeting, select an exact window_id/pid from the latest windows list; never invent a window. Stop on errors or denied permission and report the actual cause. Galaxy's Full Disk Access is separate from Screen Recording and Accessibility permissions. When desktop work is complete and terminal/file work remains, call the appropriate normal tool and Galaxy resumes the base model. Only finish the response when the whole user's task is complete.\n\n");
|
||||
}
|
||||
prompt.push_str("## Available Tools\n");
|
||||
if tools.is_empty() {
|
||||
prompt.push_str("No tools are available. Do not invent tool calls.\n");
|
||||
|
||||
@@ -242,6 +242,58 @@ fn plan_turn_requires_a_plan_tool_result() {
|
||||
assert!(prompt.contains("Do not return the plan only as prose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_and_plan_turns_advertise_clarifications_only_for_interactive_permission_modes() {
|
||||
use crate::ai::execution_profiles::{
|
||||
AIExecutionProfile, AskUserQuestionPermission, PermissionMode,
|
||||
};
|
||||
|
||||
let _flag = galaxy_core::features::FeatureFlag::AskUserQuestion.override_enabled(true);
|
||||
for (permission_mode, expected) in [
|
||||
(PermissionMode::AskFirst, true),
|
||||
(PermissionMode::AllowAll, true),
|
||||
(PermissionMode::GalaxyDecides, false),
|
||||
(PermissionMode::ReadOnly, false),
|
||||
] {
|
||||
for plan_mode in [false, true] {
|
||||
let mut profile = AIExecutionProfile {
|
||||
permission_mode,
|
||||
..Default::default()
|
||||
};
|
||||
profile.project_permission_mode();
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.permission_mode = permission_mode;
|
||||
params.ask_user_question_enabled =
|
||||
profile.ask_user_question != AskUserQuestionPermission::Never;
|
||||
let mut input = user_query("Clarify the requested destination before proceeding");
|
||||
if plan_mode {
|
||||
let AIAgentInput::UserQuery {
|
||||
user_query_mode, ..
|
||||
} = &mut input
|
||||
else {
|
||||
unreachable!("user_query returns a user query input");
|
||||
};
|
||||
*user_query_mode = UserQueryMode::Plan;
|
||||
}
|
||||
params.input = vec![input];
|
||||
let (normal, cli) =
|
||||
crate::ai::agent::api::prepare_direct_provider_params(&mut params);
|
||||
assert!(!cli.contains(&ToolType::AskUserQuestion));
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, normal, cli);
|
||||
assert_eq!(
|
||||
prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "ask_user_question"),
|
||||
expected,
|
||||
"{permission_mode:?}, plan_mode={plan_mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tools_turn_flattens_historical_tool_protocol_messages() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
@@ -270,8 +322,9 @@ fn no_tools_turn_flattens_historical_tool_protocol_messages() {
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new());
|
||||
|
||||
assert_eq!(prepared.request.tools.len(), 1);
|
||||
assert_eq!(prepared.request.tools[0].name, "recall_tool_history");
|
||||
assert_eq!(prepared.request.tools.len(), 2);
|
||||
assert!(prepared.request.tools.iter().any(|tool| tool.name == "recall_tool_history"));
|
||||
assert!(prepared.request.tools.iter().any(|tool| tool.name == "update_task_list"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.messages
|
||||
@@ -499,6 +552,20 @@ fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "recall_tool_history"));
|
||||
assert!(!prepared.request.tools.iter().any(|tool| tool.name == "update_task_list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_progress_is_available_with_explicit_status_guidance_and_ui_title() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query("Implement and verify the fix")];
|
||||
let prepared = prepare_rig_turn(&config(), params, vec![ToolType::ReadFiles], Vec::new());
|
||||
let tool = prepared.request.tools.iter().find(|tool| tool.name == "update_task_list").unwrap();
|
||||
assert!(tool.input_schema["required"].as_array().unwrap().iter().any(|value| value == "_galaxy_tool_title"));
|
||||
let prompt = prepared.request.system_prompt.unwrap();
|
||||
assert!(prompt.contains("## Task List Progress"));
|
||||
assert!(prompt.contains("reconcile the list before the final response"));
|
||||
assert!(prompt.contains("Planning or returning a draft does not complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -518,6 +585,7 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
|
||||
),
|
||||
};
|
||||
let snapshot_tool_result = ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "run-call".to_string(),
|
||||
content: result.result.model_content(),
|
||||
status: ToolResultStatus::Success,
|
||||
@@ -827,6 +895,7 @@ fn normalized_tool_outcomes_are_the_only_action_results_sent_to_rig() {
|
||||
let tool_results = statuses
|
||||
.iter()
|
||||
.map(|(call_id, status, _)| ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: (*call_id).to_string(),
|
||||
content: format!("normalized-{call_id}"),
|
||||
status: *status,
|
||||
|
||||
@@ -87,6 +87,27 @@ async fn selected_computer_use_model_gets_its_own_profile() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automatic_and_same_model_computer_use_keep_a_dedicated_profile() {
|
||||
for selected in ["computer-use-agent-auto", "base-selection", "unroutable-model"] {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.model = LLMId::from("base-selection");
|
||||
params.computer_use_model = LLMId::from(selected);
|
||||
params.computer_use_enabled = true;
|
||||
let prepared = prepare_provider_run(
|
||||
ProviderConfig::OpenAI(openai_config("base-provider-model")),
|
||||
ProviderConfig::None, ProviderConfig::None, params, Vec::new(),
|
||||
).await.unwrap();
|
||||
assert!(prepared.base_profile.request.tools.iter().any(|tool| tool.name == "request_computer_use"));
|
||||
assert!(!prepared.base_profile.request.tools.iter().any(|tool| tool.name == "use_computer"));
|
||||
let computer = prepared.computer_use_profile.unwrap();
|
||||
assert_eq!(computer.request.model.as_str(), "base-provider-model");
|
||||
assert!(computer.request.tools.iter().any(|tool| tool.name == "use_computer"));
|
||||
assert!(!computer.request.tools.iter().any(|tool| tool.name == "request_computer_use"));
|
||||
assert!(computer.request.system_prompt.unwrap().contains("Inspect the latest returned screenshot before acting"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_context_budget_uses_catalog_limit_and_clamps_stale_overrides() {
|
||||
let mut config = openai_config("subscription-model");
|
||||
|
||||
@@ -55,6 +55,23 @@ pub(crate) fn action_from_tool_call(
|
||||
rationale: optional_string(input, "rationale")?,
|
||||
citations: Vec::new(),
|
||||
},
|
||||
"request_computer_use" => AIAgentActionType::RequestComputerUse(
|
||||
crate::ai::agent::RequestComputerUseRequest {
|
||||
task_summary: required_nonempty_string(input, "task_summary")?,
|
||||
screenshot_params: Some(computer_screenshot_params(input)?),
|
||||
},
|
||||
),
|
||||
"use_computer" => AIAgentActionType::UseComputer(
|
||||
crate::ai::agent::UseComputerRequest {
|
||||
action_summary: required_nonempty_string(input, "action_summary")?,
|
||||
actions: computer_actions(input)?,
|
||||
screenshot_params: if optional_boolean(input, "take_screenshot")?.unwrap_or(true) {
|
||||
Some(computer_screenshot_params(input)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
),
|
||||
"read_files" => AIAgentActionType::ReadFiles(ReadFilesRequest {
|
||||
locations: limited_required_array(input, "files", MAX_BATCH_READ_ITEMS)?
|
||||
.iter()
|
||||
@@ -472,6 +489,113 @@ fn optional_nonnegative_i32(input: &serde_json::Value, key: &str) -> Result<Opti
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn computer_i32(input: &serde_json::Value, key: &str) -> Result<i32, String> {
|
||||
input.get(key).and_then(serde_json::Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.ok_or_else(|| format!("invalid field {key:?}: expected a 32-bit integer"))
|
||||
}
|
||||
|
||||
fn computer_target(input: &serde_json::Value) -> Result<computer_use::Target, String> {
|
||||
let Some(target) = input.get("target") else { return Ok(computer_use::Target::Screen); };
|
||||
require_object(target, "target")?;
|
||||
match required_string(target, "type")?.as_str() {
|
||||
"screen" => Ok(computer_use::Target::Screen),
|
||||
"window" => {
|
||||
let window_id = optional_bounded_u64(target, "window_id", u64::from(u32::MAX))?
|
||||
.filter(|id| *id > 0).ok_or("window target requires a non-zero window_id")? as u32;
|
||||
let pid = computer_i32(target, "pid")?;
|
||||
if pid <= 0 { return Err("window target requires a positive pid".to_string()); }
|
||||
if !crate::features::FeatureFlag::BackgroundComputerUse.is_enabled()
|
||||
|| !computer_use::background_supported()
|
||||
{
|
||||
return Err("Background window targeting is unavailable; use the screen target.".to_string());
|
||||
}
|
||||
Ok(computer_use::Target::Window { window_id, pid })
|
||||
}
|
||||
_ => Err("target.type must be screen or window".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn computer_screenshot_params(input: &serde_json::Value) -> Result<computer_use::ScreenshotParams, String> {
|
||||
let empty = serde_json::json!({});
|
||||
let params = input.get("screenshot_params").unwrap_or(&empty);
|
||||
require_object(params, "screenshot_params")?;
|
||||
let positive = |key, maximum, default| -> Result<usize, String> {
|
||||
let value = optional_bounded_u64(params, key, maximum)?.unwrap_or(default);
|
||||
if value == 0 { return Err(format!("{key} must be positive")); }
|
||||
Ok(value as usize)
|
||||
};
|
||||
Ok(computer_use::ScreenshotParams {
|
||||
max_long_edge_px: Some(positive("max_long_edge_px", 4096, 1568)?),
|
||||
max_total_px: Some(positive("max_total_px", 4_194_304, 1_150_000)?),
|
||||
region: None,
|
||||
target: computer_target(params)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn computer_actions(input: &serde_json::Value) -> Result<Vec<computer_use::TargetedAction>, String> {
|
||||
use computer_use::{Action, Key, MouseButton, ScrollDirection, ScrollDistance, TargetedAction, Vector2I};
|
||||
let mut wait_seconds = 0.0;
|
||||
limited_required_array(input, "actions", 20)?.iter().map(|input| {
|
||||
require_object(input, "action")?;
|
||||
let position = || -> Result<Vector2I, String> {
|
||||
Ok(Vector2I::new(computer_i32(input, "x")?, computer_i32(input, "y")?))
|
||||
};
|
||||
let button = || -> Result<MouseButton, String> {
|
||||
match required_string(input, "button")?.as_str() {
|
||||
"left" => Ok(MouseButton::Left), "right" => Ok(MouseButton::Right),
|
||||
"middle" => Ok(MouseButton::Middle), "back" => Ok(MouseButton::Back),
|
||||
"forward" => Ok(MouseButton::Forward),
|
||||
_ => Err("button must be left, right, middle, back, or forward".to_string()),
|
||||
}
|
||||
};
|
||||
let key = || -> Result<Key, String> {
|
||||
match (input.get("key"), input.get("keycode")) {
|
||||
(Some(_), None) => {
|
||||
let text = required_string(input, "key")?;
|
||||
let mut chars = text.chars();
|
||||
let key = chars.next().ok_or("key must contain exactly one character")?;
|
||||
if chars.next().is_some() { return Err("key must contain exactly one character".to_string()); }
|
||||
Ok(Key::Char(key))
|
||||
}
|
||||
(None, Some(_)) => Ok(Key::Keycode(computer_i32(input, "keycode")?)),
|
||||
_ => Err("provide exactly one of key or keycode".to_string()),
|
||||
}
|
||||
};
|
||||
let action = match required_string(input, "type")?.as_str() {
|
||||
"mouse_move" => Action::MouseMove { to: position()? },
|
||||
"mouse_down" => Action::MouseDown { button: button()?, at: position()? },
|
||||
"mouse_up" => Action::MouseUp { button: button()? },
|
||||
"mouse_wheel" => Action::MouseWheel {
|
||||
at: position()?,
|
||||
direction: match required_string(input, "direction")?.as_str() {
|
||||
"up" => ScrollDirection::Up, "down" => ScrollDirection::Down,
|
||||
"left" => ScrollDirection::Left, "right" => ScrollDirection::Right,
|
||||
_ => return Err("direction must be up, down, left, or right".to_string()),
|
||||
},
|
||||
distance: ScrollDistance::Pixels(optional_nonnegative_i32(input, "pixels")?.ok_or("mouse_wheel requires pixels")?),
|
||||
},
|
||||
"type_text" => {
|
||||
let text = required_string(input, "text")?;
|
||||
if text.len() > 100_000 { return Err("text must be no longer than 100000 bytes".to_string()); }
|
||||
Action::TypeText { text }
|
||||
}
|
||||
"key_down" => Action::KeyDown { key: key()? },
|
||||
"key_up" => Action::KeyUp { key: key()? },
|
||||
"wait" => {
|
||||
let seconds = input.get("seconds").and_then(serde_json::Value::as_f64)
|
||||
.filter(|seconds| seconds.is_finite() && *seconds >= 0.0 && *seconds <= 10.0)
|
||||
.ok_or("seconds must be between 0 and 10")?;
|
||||
wait_seconds += seconds;
|
||||
if wait_seconds > 10.0 { return Err("a computer-use batch may wait at most 10 seconds".to_string()); }
|
||||
Action::Wait(Duration::from_secs_f64(seconds))
|
||||
}
|
||||
_ => return Err("unknown computer action type".to_string()),
|
||||
};
|
||||
Ok(TargetedAction { action, target: computer_target(input)? })
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn skill_references(
|
||||
input: &serde_json::Value,
|
||||
skill_path_origin: &SkillPathOrigin,
|
||||
|
||||
@@ -16,6 +16,55 @@ fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_request_always_captures_a_bounded_initial_screenshot() {
|
||||
let action = action_from_tool_call("task", &call("request_computer_use", serde_json::json!({
|
||||
"task_summary": "Inspect the browser",
|
||||
})), &SkillPathOrigin::Local, &HashMap::new()).unwrap();
|
||||
let AIAgentActionType::RequestComputerUse(request) = action.action else { panic!("expected computer-use request"); };
|
||||
let screenshot = request.screenshot_params.unwrap();
|
||||
assert_eq!(screenshot.max_long_edge_px, Some(1568));
|
||||
assert_eq!(screenshot.max_total_px, Some(1_150_000));
|
||||
assert_eq!(screenshot.target, computer_use::Target::Screen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_actions_preserve_coordinates_keys_and_screenshot_choices() {
|
||||
let action = action_from_tool_call("task", &call("use_computer", serde_json::json!({
|
||||
"action_summary": "Select the field and type",
|
||||
"actions": [
|
||||
{ "type": "mouse_down", "button": "left", "x": 420, "y": 310 },
|
||||
{ "type": "mouse_up", "button": "left" },
|
||||
{ "type": "type_text", "text": "hello" },
|
||||
{ "type": "key_down", "keycode": 36 },
|
||||
{ "type": "key_up", "keycode": 36 },
|
||||
],
|
||||
"take_screenshot": false,
|
||||
})), &SkillPathOrigin::Local, &HashMap::new()).unwrap();
|
||||
let AIAgentActionType::UseComputer(request) = action.action else { panic!("expected computer actions"); };
|
||||
assert_eq!(request.actions.len(), 5);
|
||||
assert_eq!(request.actions[0].action, computer_use::Action::MouseDown {
|
||||
button: computer_use::MouseButton::Left, at: computer_use::Vector2I::new(420, 310),
|
||||
});
|
||||
assert_eq!(request.actions[3].action, computer_use::Action::KeyDown { key: computer_use::Key::Keycode(36) });
|
||||
assert_eq!(request.screenshot_params, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_actions_reject_invalid_targets_keys_and_unbounded_waiting() {
|
||||
for actions in [
|
||||
serde_json::json!([{ "type": "key_down", "key": "Return" }]),
|
||||
serde_json::json!([{ "type": "key_up", "key": "a", "keycode": 0 }]),
|
||||
serde_json::json!([{ "type": "mouse_move", "x": 1, "y": 2, "target": { "type": "window", "window_id": 0, "pid": 12 } }]),
|
||||
serde_json::json!([{ "type": "wait", "seconds": 6 }, { "type": "wait", "seconds": 6 }]),
|
||||
serde_json::json!(vec![serde_json::json!({ "type": "wait", "seconds": 0 }); 21]),
|
||||
] {
|
||||
assert!(action_from_tool_call("task", &call("use_computer", serde_json::json!({
|
||||
"action_summary": "Invalid request", "actions": actions,
|
||||
})), &SkillPathOrigin::Local, &HashMap::new()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_calls_become_domain_actions_without_a_proto_round_trip() {
|
||||
let action = action_from_tool_call(
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Task state is kept outside the compactable transcript and projected only from
|
||||
//! explicit, validated model updates. Finishing a response never finishes tasks.
|
||||
|
||||
use galaxy_agent_core::{
|
||||
PendingToolBatch, PendingToolCallState, ProviderRun, ProviderRunState, TaskListItem,
|
||||
TaskListSnapshot, TaskListStatus, ToolDefinition, ToolResultStatus, TurnRequest,
|
||||
UPDATE_TASK_LIST_NAME,
|
||||
};
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
const SNAPSHOT_START: &str = "\n<galaxy-task-list>\n";
|
||||
const SNAPSHOT_END: &str = "\n</galaxy-task-list>\n";
|
||||
const TASK_LIST_REMINDER: &str = "\nKeep the current task list synchronized with the work at meaningful milestones and before finishing. If a step is now done or work has moved to another step, call update_task_list with the evidence-based status. Pending work must remain visible.\n";
|
||||
|
||||
pub(super) const TASK_PROGRESS_GUIDANCE: &str = "\n## Task List Progress\nUse `update_task_list` to keep the visible checklist current throughout multi-step work. It accepts the complete list, with stable task IDs, titles, and pending/in_progress/completed statuses. Keep at most one task in progress and place it before pending tasks. After creating a plan, use its checklist and IDs from the current task list; do not create a competing list. Update when starting the next step, completing a meaningful step, changing scope, or discovering a blocker, and reconcile the list before the final response. Preserve unfinished tasks when blocked or waiting for a decision; explain the blocker in the task title if needed. Mark completed only when the work and required verification actually succeeded. Planning or returning a draft does not complete its implementation tasks. Do not recreate an unchanged list, update after every poll, or spend turns just restating progress. Tool panes show work; task updates do not require prose announcements. CLI command monitoring must continue using its advertised monitoring tools.\n";
|
||||
|
||||
pub(super) fn task_list_tool() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: UPDATE_TASK_LIST_NAME.to_owned(),
|
||||
description: "Create or update the visible task list for this conversation. Send the complete list with stable IDs, retaining completed and unfinished work. Update at meaningful milestones, when the current step changes, and before finishing; mark completed only after the work and required checks succeed. This records task progress only and does not execute the tasks or edit the plan document.".to_owned(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array", "minItems": 1, "maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||||
},
|
||||
"required": ["id", "title", "status"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["tasks"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn task_list_from_api_tasks(
|
||||
tasks: &[api::Task],
|
||||
root_task_id: &str,
|
||||
) -> Option<TaskListSnapshot> {
|
||||
use api::message::update_todos::Operation;
|
||||
|
||||
let task = tasks.iter().find(|task| task.id == root_task_id)?;
|
||||
let mut pending = Vec::<api::TodoItem>::new();
|
||||
let mut completed = Vec::<api::TodoItem>::new();
|
||||
for message in &task.messages {
|
||||
let Some(api::message::Message::UpdateTodos(update)) = &message.message else {
|
||||
continue;
|
||||
};
|
||||
let Some(operation) = update.operation.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
match operation {
|
||||
Operation::CreateTodoList(create) => {
|
||||
pending = create.initial_todos.clone();
|
||||
completed.clear();
|
||||
}
|
||||
Operation::UpdatePendingTodos(update) => {
|
||||
pending = update.updated_pending_todos.clone();
|
||||
}
|
||||
Operation::MarkTodosCompleted(mark) => {
|
||||
for id in &mark.todo_ids {
|
||||
if let Some(index) = pending.iter().position(|task| task.id == *id) {
|
||||
let item = pending.remove(index);
|
||||
if let Some(old) = completed.iter_mut().find(|task| task.id == *id) {
|
||||
*old = item;
|
||||
} else {
|
||||
completed.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let snapshot = TaskListSnapshot {
|
||||
tasks: completed
|
||||
.into_iter()
|
||||
.map(|task| task_item(task, TaskListStatus::Completed))
|
||||
.chain(pending.into_iter().enumerate().map(|(index, task)| {
|
||||
task_item(
|
||||
task,
|
||||
if index == 0 {
|
||||
TaskListStatus::InProgress
|
||||
} else {
|
||||
TaskListStatus::Pending
|
||||
},
|
||||
)
|
||||
}))
|
||||
.collect(),
|
||||
};
|
||||
snapshot.validate().ok()?;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn task_item(task: api::TodoItem, status: TaskListStatus) -> TaskListItem {
|
||||
TaskListItem {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn initial_task_list(request: &TurnRequest) -> Option<TaskListSnapshot> {
|
||||
let prompt = request.system_prompt.as_deref()?;
|
||||
let (_, snapshot) = prompt.rsplit_once(SNAPSHOT_START)?;
|
||||
let (snapshot, _) = snapshot.split_once(SNAPSHOT_END)?;
|
||||
let snapshot: TaskListSnapshot = serde_json::from_str(snapshot).ok()?;
|
||||
snapshot.validate().ok()?;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
pub(super) fn add_task_list_context(request: &mut TurnRequest, snapshot: Option<&TaskListSnapshot>) {
|
||||
let Some(prompt) = request.system_prompt.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if prompt.ends_with(TASK_LIST_REMINDER) {
|
||||
prompt.truncate(prompt.len() - TASK_LIST_REMINDER.len());
|
||||
}
|
||||
if let Some(start) = prompt.rfind(SNAPSHOT_START) {
|
||||
if let Some(end) = prompt[start..].find(SNAPSHOT_END) {
|
||||
prompt.replace_range(start..start + end + SNAPSHOT_END.len(), "");
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = snapshot {
|
||||
prompt.push_str(SNAPSHOT_START);
|
||||
prompt.push_str(&serde_json::to_string(snapshot).expect("serializable task list"));
|
||||
prompt.push_str(SNAPSHOT_END);
|
||||
}
|
||||
if request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == UPDATE_TASK_LIST_NAME)
|
||||
{
|
||||
prompt.push_str(TASK_LIST_REMINDER);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the existing plan checklist behavior, with stable IDs shared by the
|
||||
/// UI and the next provider request. Rejected tools cannot create task lists.
|
||||
pub(super) fn task_list_from_plan_batch(batch: &PendingToolBatch) -> Option<TaskListSnapshot> {
|
||||
let plan_call = batch.calls.iter().find(|pending| {
|
||||
matches!(pending.call.name.as_str(), "create_plan" | "create_documents")
|
||||
&& !matches!(
|
||||
&pending.state,
|
||||
PendingToolCallState::Resolved { result }
|
||||
if result.status != galaxy_agent_core::ToolResultStatus::Success
|
||||
)
|
||||
})?;
|
||||
let content = plan_call
|
||||
.call
|
||||
.arguments
|
||||
.get("documents")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("content")?
|
||||
.as_str()?;
|
||||
let mut lines = content.lines();
|
||||
lines.find(|line| matches!(line.trim(), "## Tasks" | "## Implementation Tasks"))?;
|
||||
let tasks = lines
|
||||
.take_while(|line| !line.trim().starts_with("## "))
|
||||
.filter_map(|line| line.trim().strip_prefix("- [ ]").map(str::trim))
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(50)
|
||||
.enumerate()
|
||||
.map(|(index, item)| TaskListItem {
|
||||
id: format!("plan-{}-{index}", plan_call.call.id),
|
||||
title: item
|
||||
.split_once(" - ")
|
||||
.map_or(item, |(title, _)| title)
|
||||
.trim()
|
||||
.chars()
|
||||
.take(1_000)
|
||||
.collect(),
|
||||
status: TaskListStatus::Pending,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if tasks.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let snapshot = TaskListSnapshot { tasks };
|
||||
snapshot.validate().ok()?;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn todo_item(task: &TaskListItem) -> api::TodoItem {
|
||||
api::TodoItem {
|
||||
id: task.id.clone(),
|
||||
title: task.title.clone(),
|
||||
description: task.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn explicit_task_updates(batch: &PendingToolBatch) -> Vec<TaskListSnapshot> {
|
||||
batch
|
||||
.calls
|
||||
.iter()
|
||||
.filter_map(|pending| {
|
||||
if pending.call.name != UPDATE_TASK_LIST_NAME {
|
||||
return None;
|
||||
}
|
||||
let PendingToolCallState::Resolved { result } = &pending.state else {
|
||||
return None;
|
||||
};
|
||||
if result.status != ToolResultStatus::Success {
|
||||
return None;
|
||||
}
|
||||
galaxy_agent_core::task_list_from_tool_result(&result.content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn restored_task_list(run: &ProviderRun) -> Option<TaskListSnapshot> {
|
||||
// A mixed batch can have projected an inline update before the other tools
|
||||
// finish. Conversely an inline-only batch can commit during normalization
|
||||
// before its UI projection was ever persisted. Reconcile both crash windows.
|
||||
if let ProviderRunState::AwaitingTools { batch } = run.state() {
|
||||
if let Some(snapshot) = explicit_task_updates(batch).pop() {
|
||||
return Some(snapshot);
|
||||
}
|
||||
}
|
||||
run.task_list().cloned()
|
||||
}
|
||||
|
||||
pub(super) fn todo_operations(
|
||||
previous: Option<&TaskListSnapshot>,
|
||||
snapshot: &TaskListSnapshot,
|
||||
) -> Vec<api::message::update_todos::Operation> {
|
||||
use api::message::update_todos::Operation;
|
||||
|
||||
if previous == Some(snapshot) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Existing completed items are immutable in the transcript protocol. A new
|
||||
// scope or reopening a completed item therefore starts a replacement list.
|
||||
let replace = previous.is_none_or(|previous| {
|
||||
previous.tasks.iter().any(|old| {
|
||||
old.status == TaskListStatus::Completed
|
||||
&& !snapshot.tasks.iter().any(|task| {
|
||||
task.id == old.id
|
||||
&& task.title == old.title
|
||||
&& task.status == TaskListStatus::Completed
|
||||
})
|
||||
})
|
||||
});
|
||||
let already_completed = |id: &str| {
|
||||
!replace
|
||||
&& previous.is_some_and(|previous| {
|
||||
previous
|
||||
.tasks
|
||||
.iter()
|
||||
.any(|task| task.id == id && task.status == TaskListStatus::Completed)
|
||||
})
|
||||
};
|
||||
let todos = snapshot
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| !already_completed(&task.id))
|
||||
.map(todo_item)
|
||||
.collect::<Vec<_>>();
|
||||
let mut operations = vec![if replace {
|
||||
Operation::CreateTodoList(api::CreateTodoList {
|
||||
initial_todos: todos,
|
||||
})
|
||||
} else {
|
||||
Operation::UpdatePendingTodos(api::UpdatePendingTodos {
|
||||
updated_pending_todos: todos,
|
||||
})
|
||||
}];
|
||||
let todo_ids = snapshot
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == TaskListStatus::Completed && !already_completed(&task.id))
|
||||
.map(|task| task.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if !todo_ids.is_empty() {
|
||||
operations.push(Operation::MarkTodosCompleted(api::MarkTodosCompleted {
|
||||
todo_ids,
|
||||
}));
|
||||
}
|
||||
operations
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "task_progress_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
fn snapshot(tasks: &[(&str, TaskListStatus)]) -> TaskListSnapshot {
|
||||
TaskListSnapshot {
|
||||
tasks: tasks.iter().map(|(id, status)| TaskListItem {
|
||||
id: (*id).to_owned(),
|
||||
title: format!("Task {id}"),
|
||||
status: *status,
|
||||
}).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_with_operations(operations: Vec<api::message::update_todos::Operation>) -> api::Task {
|
||||
api::Task {
|
||||
id: "root".to_owned(),
|
||||
messages: operations.into_iter().map(|operation| api::Message {
|
||||
message: Some(api::message::Message::UpdateTodos(api::message::UpdateTodos {
|
||||
operation: Some(operation),
|
||||
})),
|
||||
..Default::default()
|
||||
}).collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_status_updates_reuse_the_list_and_survive_a_followup_request() {
|
||||
use TaskListStatus::{Completed, InProgress, Pending};
|
||||
use api::message::update_todos::Operation;
|
||||
|
||||
let initial = snapshot(&[("inspect", InProgress), ("implement", Pending), ("verify", Pending)]);
|
||||
let next = snapshot(&[("inspect", Completed), ("implement", InProgress), ("verify", Pending)]);
|
||||
let updates = todo_operations(Some(&initial), &next);
|
||||
assert!(matches!(&updates[0], Operation::UpdatePendingTodos(_)));
|
||||
assert!(matches!(&updates[1], Operation::MarkTodosCompleted(mark) if mark.todo_ids == ["inspect"]));
|
||||
let mut operations = todo_operations(None, &initial);
|
||||
operations.extend(updates);
|
||||
let root = task_with_operations(operations);
|
||||
let unrelated_child = api::Task { id: "child".to_owned(), ..Default::default() };
|
||||
assert_eq!(task_list_from_api_tasks(&[unrelated_child, root], "root"), Some(next.clone()));
|
||||
assert!(todo_operations(Some(&next), &next).is_empty());
|
||||
|
||||
let finished = snapshot(&[("inspect", Completed), ("implement", Completed), ("verify", Completed)]);
|
||||
let operations = todo_operations(Some(&next), &finished);
|
||||
let Operation::UpdatePendingTodos(update) = &operations[0] else {
|
||||
panic!("normal progress preserves the existing checklist");
|
||||
};
|
||||
assert_eq!(update.updated_pending_todos.len(), 2);
|
||||
assert!(!update.updated_pending_todos.iter().any(|task| task.id == "inspect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_completed_work_replaces_the_protocol_list_without_losing_ids() {
|
||||
use TaskListStatus::{Completed, InProgress};
|
||||
let old = snapshot(&[("inspect", Completed), ("verify", Completed)]);
|
||||
let reopened = snapshot(&[("inspect", Completed), ("verify", InProgress)]);
|
||||
let operations = todo_operations(Some(&old), &reopened);
|
||||
assert!(matches!(&operations[0], api::message::update_todos::Operation::CreateTodoList(_)));
|
||||
let root = task_with_operations(operations);
|
||||
assert_eq!(task_list_from_api_tasks(&[root], "root"), Some(reopened));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redelivered_progress_cannot_duplicate_completed_items_in_followup_state() {
|
||||
use TaskListStatus::{Completed, InProgress, Pending};
|
||||
let initial = snapshot(&[("inspect", InProgress), ("verify", Pending)]);
|
||||
let next = snapshot(&[("inspect", Completed), ("verify", InProgress)]);
|
||||
let update = todo_operations(Some(&initial), &next);
|
||||
let mut operations = todo_operations(None, &initial);
|
||||
operations.extend(update.clone());
|
||||
operations.extend(update);
|
||||
assert_eq!(task_list_from_api_tasks(&[task_with_operations(operations)], "root"), Some(next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_snapshot_replaces_stale_prompt_state_without_accumulating_reminders() {
|
||||
let initial = snapshot(&[("inspect", TaskListStatus::InProgress)]);
|
||||
let current = snapshot(&[("inspect", TaskListStatus::Completed)]);
|
||||
let mut request = TurnRequest::new("model", Vec::new());
|
||||
request.system_prompt = Some("Follow user instructions.\n".to_owned());
|
||||
request.tools = vec![task_list_tool()];
|
||||
add_task_list_context(&mut request, Some(&initial));
|
||||
assert_eq!(initial_task_list(&request), Some(initial));
|
||||
add_task_list_context(&mut request, Some(¤t));
|
||||
let prompt = request.system_prompt.clone();
|
||||
add_task_list_context(&mut request, Some(¤t));
|
||||
assert_eq!(request.system_prompt, prompt);
|
||||
assert_eq!(initial_task_list(&request), Some(current));
|
||||
let prompt = request.system_prompt.unwrap();
|
||||
assert_eq!(prompt.matches(SNAPSHOT_START).count(), 1);
|
||||
assert_eq!(prompt.matches(TASK_LIST_REMINDER).count(), 1);
|
||||
assert!(prompt.starts_with("Follow user instructions."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_plan_creation_cannot_seed_visible_or_durable_tasks() {
|
||||
let batch = PendingToolBatch {
|
||||
work_id: galaxy_agent_core::ExternalWorkId {
|
||||
run_id: galaxy_agent_core::ProviderRunId::new("run"),
|
||||
epoch: galaxy_agent_core::RunEpoch::new(1),
|
||||
},
|
||||
calls: vec![galaxy_agent_core::PendingToolCall {
|
||||
call: galaxy_agent_core::ToolCall {
|
||||
id: "plan".to_owned(),
|
||||
name: "create_plan".to_owned(),
|
||||
arguments: serde_json::json!({"documents": [{"content": "## Tasks\n- [ ] Inspect\n- [ ] Verify"}]}),
|
||||
},
|
||||
state: PendingToolCallState::Resolved { result: galaxy_agent_core::ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "plan".to_owned(),
|
||||
content: "Not advertised".to_owned(),
|
||||
status: galaxy_agent_core::ToolResultStatus::Error,
|
||||
}},
|
||||
}],
|
||||
};
|
||||
assert!(task_list_from_plan_batch(&batch).is_none());
|
||||
}
|
||||
@@ -158,6 +158,12 @@ fn make_new_app_menu(ctx: &AppContext) -> Menu {
|
||||
|
||||
let preferences_menu_items = vec![
|
||||
updateable_custom_item_without_checkmark(CustomAction::ShowSettings, ctx),
|
||||
MenuItem::Custom(CustomMenuItem::new(
|
||||
"Full Disk Access Settings...",
|
||||
crate::full_disk_access::open_settings,
|
||||
no_updates,
|
||||
None,
|
||||
)),
|
||||
MenuItem::Separator,
|
||||
updateable_custom_item_without_checkmark(CustomAction::ToggleKeybindingsPage, ctx),
|
||||
updateable_custom_item_without_checkmark(CustomAction::ConfigureKeybindings, ctx),
|
||||
|
||||
@@ -1729,7 +1729,7 @@ impl View for CodeFooterView {
|
||||
Self::render_status_text(
|
||||
theme,
|
||||
appearance,
|
||||
"Use Oz to update this config".to_string(),
|
||||
"Use Galaxy Agent to update this config".to_string(),
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
|
||||
+125
-21
@@ -41,7 +41,7 @@ use super::drive_helpers::{
|
||||
use super::empty_trash_confirmation_dialog::{
|
||||
EmptyTrashConfirmationDialog, EmptyTrashConfirmationEvent,
|
||||
};
|
||||
use super::folders::CloudFolder;
|
||||
use super::folders::{CloudFolder, CloudFolderModel, FolderId};
|
||||
use super::items::ai_fact_collection::WarpDriveAIFactCollection;
|
||||
use super::items::item::{tools_panel_menu_direction, ItemStates, WarpDriveRow};
|
||||
use super::items::mcp_server_collection::WarpDriveMCPServerCollection;
|
||||
@@ -69,7 +69,7 @@ use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
|
||||
use crate::env_vars::CloudEnvVarCollection;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields, SubMenu};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
@@ -1060,6 +1060,21 @@ impl DriveIndex {
|
||||
false
|
||||
}
|
||||
|
||||
fn move_operation_allowed(
|
||||
&self,
|
||||
cloud_object_type_and_id: &CloudObjectTypeAndId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
if let Some(object) = cloud_model.get_by_uid(&cloud_object_type_and_id.uid()) {
|
||||
if object.permissions().owner == local_owner() {
|
||||
return !object.is_trashed(cloud_model)
|
||||
&& !object.metadata().has_pending_online_only_change();
|
||||
}
|
||||
}
|
||||
self.online_only_operation_allowed(cloud_object_type_and_id, app)
|
||||
}
|
||||
|
||||
fn is_online(&self, app: &AppContext) -> bool {
|
||||
NetworkStatus::as_ref(app).is_online()
|
||||
}
|
||||
@@ -2685,7 +2700,7 @@ impl DriveIndex {
|
||||
// If the share dialog is open, we don't want to open the menu for the same object.
|
||||
let menu_open =
|
||||
self.menu_object_id_if_open == Some(warp_drive_item_id) && !share_dialog_open;
|
||||
let can_move = self.online_only_operation_allowed(&row_object_id, app)
|
||||
let can_move = self.move_operation_allowed(&row_object_id, app)
|
||||
&& matches!(self.index_variant, DriveIndexVariant::MainIndex)
|
||||
&& access_level.can_move_drive();
|
||||
let mut is_focused = false;
|
||||
@@ -3239,12 +3254,15 @@ impl DriveIndex {
|
||||
) {
|
||||
// TODO: we need to tell the user why 'move' is not working.
|
||||
let is_drop_target_valid = match new_location {
|
||||
CloudObjectLocation::Folder(folder_id) => self.online_only_operation_allowed(
|
||||
&CloudObjectTypeAndId::from_id_and_type(folder_id, ObjectType::Folder),
|
||||
ctx,
|
||||
),
|
||||
CloudObjectLocation::Space(_) => self.is_online(ctx),
|
||||
CloudObjectLocation::Trash => self.is_online(ctx),
|
||||
CloudObjectLocation::Folder(folder_id) => {
|
||||
Self::is_local_folder(&folder_id, ctx)
|
||||
|| self.online_only_operation_allowed(
|
||||
&CloudObjectTypeAndId::from_id_and_type(folder_id, ObjectType::Folder),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
CloudObjectLocation::Space(space) => space == Space::Personal || self.is_online(ctx),
|
||||
CloudObjectLocation::Trash => true,
|
||||
};
|
||||
|
||||
if is_drop_target_valid {
|
||||
@@ -3275,23 +3293,37 @@ impl DriveIndex {
|
||||
self.current_drop_target = None;
|
||||
ctx.notify();
|
||||
|
||||
if !self.online_only_operation_allowed(cloud_object_type_and_id, ctx) {
|
||||
if !self.move_operation_allowed(cloud_object_type_and_id, ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cloud_model = CloudModel::handle(ctx);
|
||||
let is_local = cloud_model
|
||||
.as_ref(ctx)
|
||||
.get_by_uid(&cloud_object_type_and_id.uid())
|
||||
.is_some_and(|object| object.permissions().owner == local_owner());
|
||||
let can_move = if is_local {
|
||||
LocalObjectRepository::can_move_object_to_location(
|
||||
*cloud_object_type_and_id,
|
||||
new_location,
|
||||
ctx,
|
||||
)
|
||||
} else {
|
||||
cloud_model.as_ref(ctx).can_move_object_to_location(
|
||||
&cloud_object_type_and_id.uid(),
|
||||
new_location,
|
||||
ctx,
|
||||
)
|
||||
};
|
||||
|
||||
// Only proceed if we can move the object to this location AND the operation results in a change.
|
||||
// Even though an object technically can be moved to its current location (can_move_object_to_location returns true)
|
||||
// we do not need to do any of the update logic that follows.
|
||||
if !cloud_model.as_ref(ctx).can_move_object_to_location(
|
||||
&cloud_object_type_and_id.uid(),
|
||||
new_location,
|
||||
ctx,
|
||||
) || cloud_model
|
||||
.as_ref(ctx)
|
||||
.object_location(&cloud_object_type_and_id.uid(), ctx)
|
||||
.is_none_or(|location| location == new_location)
|
||||
if !can_move
|
||||
|| cloud_model
|
||||
.as_ref(ctx)
|
||||
.object_location(&cloud_object_type_and_id.uid(), ctx)
|
||||
.is_none_or(|location| location == new_location)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -3329,9 +3361,15 @@ impl DriveIndex {
|
||||
}
|
||||
|
||||
// Otherwise allow object move to go through.
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.move_object_to_location(*cloud_object_type_and_id, new_location, ctx);
|
||||
});
|
||||
if is_local {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.move_object_to_location(*cloud_object_type_and_id, new_location, ctx);
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.move_object_to_location(*cloud_object_type_and_id, new_location, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
match new_location {
|
||||
CloudObjectLocation::Space(space) => self.open_section_of_space(space),
|
||||
@@ -4873,6 +4911,13 @@ impl DriveIndex {
|
||||
}
|
||||
}
|
||||
|
||||
if self.move_operation_allowed(cloud_object_type_and_id, app) && access_level.can_move_drive()
|
||||
{
|
||||
if let Some(move_menu) = self.local_move_menu_item(*cloud_object_type_and_id, app) {
|
||||
menu_items.push(move_menu);
|
||||
}
|
||||
}
|
||||
|
||||
if can_trash && (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash()) {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Trash")
|
||||
@@ -4887,6 +4932,65 @@ impl DriveIndex {
|
||||
menu_items
|
||||
}
|
||||
|
||||
fn local_move_menu_item(
|
||||
&self,
|
||||
object_id: CloudObjectTypeAndId,
|
||||
app: &AppContext,
|
||||
) -> Option<MenuItem<DriveIndexAction>> {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let object = cloud_model.get_by_uid(&object_id.uid())?;
|
||||
if object.permissions().owner != local_owner() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut destinations = Vec::new();
|
||||
if object.metadata().folder_id.is_some() {
|
||||
destinations.push((
|
||||
"Personal (top level)".to_string(),
|
||||
CloudObjectLocation::Space(Space::Personal),
|
||||
));
|
||||
}
|
||||
destinations.extend(
|
||||
cloud_model
|
||||
.get_all_objects_of_type::<FolderId, CloudFolderModel>()
|
||||
.filter(|folder| object.metadata().folder_id != Some(folder.id))
|
||||
.filter(|folder| {
|
||||
LocalObjectRepository::can_move_object_to_location(
|
||||
object_id,
|
||||
CloudObjectLocation::Folder(folder.id),
|
||||
app,
|
||||
)
|
||||
})
|
||||
.map(|folder| {
|
||||
(
|
||||
format!("{} / {}", folder.breadcrumbs(app), folder.model().name),
|
||||
CloudObjectLocation::Folder(folder.id),
|
||||
)
|
||||
})
|
||||
.sorted_by(|(left, _), (right, _)| left.cmp(right)),
|
||||
);
|
||||
if destinations.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let items = destinations
|
||||
.into_iter()
|
||||
.map(|(label, location)| {
|
||||
MenuItemFields::new(label)
|
||||
.with_on_select_action(DriveIndexAction::DropIndexItem {
|
||||
cloud_object_type_and_id: object_id,
|
||||
drop_target_location: location,
|
||||
})
|
||||
.with_icon(Icon::Folder)
|
||||
.into_item()
|
||||
})
|
||||
.collect();
|
||||
Some(MenuItem::Submenu {
|
||||
fields: MenuItemFields::new_submenu("Move to").with_icon(Icon::Move),
|
||||
menu: SubMenu::new(items),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builder for a menu item to open a Warp Drive object in a pane. The icon and label depend
|
||||
/// on whether the object is editable or not.
|
||||
///
|
||||
|
||||
@@ -11,10 +11,12 @@ use crate::cloud_object::model::actions::ObjectActions;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::{
|
||||
CloudObjectSyncStatus, ObjectIdType, ObjectType, Owner, ServerCreationInfo, Space,
|
||||
CloudObjectLocation, CloudObjectSyncStatus, ObjectIdType, ObjectType, Owner,
|
||||
ServerCreationInfo, Space,
|
||||
};
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::local_object_repository::LocalObjectRepository;
|
||||
use crate::menu::MenuItem;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||
@@ -125,6 +127,69 @@ fn label_for_menu_item(item: &MenuItem<DriveIndexAction>) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_drive_objects_can_move_into_and_out_of_plans_offline() {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let plans_id = SyncId::ClientId(ClientId::new());
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(plans_id, "Plans".to_string(), None, ctx);
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
None,
|
||||
CloudNotebookModel::default(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
|
||||
network_status.reachability_changed(false, ctx);
|
||||
});
|
||||
let index = create_index(&mut app);
|
||||
let object_id = CloudObjectTypeAndId::Notebook(notebook_id);
|
||||
|
||||
index.update(&mut app, |index, ctx| {
|
||||
assert!(index.move_operation_allowed(&object_id, ctx));
|
||||
index.update_drop_target_location(CloudObjectLocation::Folder(plans_id), ctx);
|
||||
assert_eq!(
|
||||
index.current_drop_target,
|
||||
Some(CloudObjectLocation::Folder(plans_id)),
|
||||
);
|
||||
let Some(MenuItem::Submenu { fields, menu }) =
|
||||
index.local_move_menu_item(object_id, ctx)
|
||||
else {
|
||||
panic!("expected a local Move to submenu");
|
||||
};
|
||||
assert_eq!(fields.label(), "Move to");
|
||||
assert_eq!(label_for_menu_item(&menu.items()[0]), "Personal / Plans");
|
||||
|
||||
index.move_object(&object_id, CloudObjectLocation::Folder(plans_id), ctx);
|
||||
assert_eq!(
|
||||
CloudModel::as_ref(ctx)
|
||||
.get_notebook(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(plans_id),
|
||||
);
|
||||
let Some(MenuItem::Submenu { menu, .. }) = index.local_move_menu_item(object_id, ctx)
|
||||
else {
|
||||
panic!("expected moving the notebook back to the top level to be offered");
|
||||
};
|
||||
assert_eq!(label_for_menu_item(&menu.items()[0]), "Personal (top level)");
|
||||
|
||||
index.move_object(&object_id, CloudObjectLocation::Space(Space::Personal), ctx);
|
||||
assert!(CloudModel::as_ref(ctx)
|
||||
.get_notebook(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id
|
||||
.is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_menu_item_visibility() {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::drive::index::{
|
||||
use crate::drive::items::WarpDriveItem;
|
||||
use crate::drive::panel::WARP_DRIVE_POSITION_ID;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::menu::Menu;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::{Icon, ICON_DIMENSIONS};
|
||||
@@ -826,11 +827,24 @@ impl UiComponent for WarpDriveRow<'_> {
|
||||
return AcceptedByDropTarget::No;
|
||||
};
|
||||
let cloud_model = CloudModel::handle(app);
|
||||
if cloud_model.as_ref(app).can_move_object_to_location(
|
||||
&item.uid(),
|
||||
*location,
|
||||
app,
|
||||
) {
|
||||
let is_local = cloud_model
|
||||
.as_ref(app)
|
||||
.get_by_uid(&item.uid())
|
||||
.is_some_and(|object| {
|
||||
object.permissions().owner == local_owner()
|
||||
});
|
||||
let can_move = if is_local {
|
||||
LocalObjectRepository::can_move_object_to_location(
|
||||
item, *location, app,
|
||||
)
|
||||
} else {
|
||||
cloud_model.as_ref(app).can_move_object_to_location(
|
||||
&item.uid(),
|
||||
*location,
|
||||
app,
|
||||
)
|
||||
};
|
||||
if can_move {
|
||||
AcceptedByDropTarget::Yes
|
||||
} else {
|
||||
AcceptedByDropTarget::No
|
||||
|
||||
@@ -1704,7 +1704,7 @@ impl WorkflowModal {
|
||||
.finish();
|
||||
|
||||
let button_with_tool_tip = appearance.ui_builder().tool_tip_on_element(
|
||||
"Generate a title, descriptions, or parameters with Warp AI".to_string(),
|
||||
"Generate a title, descriptions, or parameters with Galaxy Agent".to_string(),
|
||||
self.button_mouse_states.ai_assist_tool_tip.clone(),
|
||||
rendered_button,
|
||||
ParentAnchor::BottomMiddle,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Optional, device-local guidance for macOS Full Disk Access.
|
||||
//!
|
||||
//! macOS requires the user to grant this permission in System Settings. The
|
||||
//! stored marker records dismissal of the guidance, never authorization.
|
||||
//! A conservative filesystem preflight checks effective protected-file access;
|
||||
//! macOS provides no supported API for querying the Full Disk Access toggle.
|
||||
|
||||
use galaxy_core::user_preferences::GetUserPreferences;
|
||||
use galaxyui::modals::{AlertDialogWithCallbacks, ModalButton};
|
||||
use galaxyui::AppContext;
|
||||
|
||||
const PROMPT_DISMISSED_KEY: &str = "FullDiskAccessPromptDismissed";
|
||||
const SETTINGS_URL: &str =
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ProtectedFileAccess {
|
||||
Accessible,
|
||||
Denied,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Checks access after the macOS GUI has opened, unless reminders were disabled.
|
||||
pub(crate) fn prompt_on_startup(ctx: &mut AppContext) {
|
||||
if !cfg!(target_os = "macos") || prompt_dismissed(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (sender, receiver) = futures::channel::oneshot::channel();
|
||||
ctx.background_executor()
|
||||
.spawn(async move {
|
||||
let _ = sender.send(protected_file_access());
|
||||
})
|
||||
.detach();
|
||||
|
||||
let weak_app = ctx.weak_app();
|
||||
ctx.foreground_executor()
|
||||
.spawn(async move {
|
||||
if receiver.await.ok() != Some(ProtectedFileAccess::Denied) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut app) = weak_app.upgrade() {
|
||||
app.update(|ctx| {
|
||||
if ctx.window_ids().next().is_some() && !prompt_dismissed(ctx) {
|
||||
show_guidance(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn prompt_dismissed(ctx: &AppContext) -> bool {
|
||||
match ctx
|
||||
.private_user_preferences()
|
||||
.read_value(PROMPT_DISMISSED_KEY)
|
||||
{
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(error) => {
|
||||
log::warn!("Could not read Full Disk Access guidance preference: {error}");
|
||||
true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// This is an effective-access heuristic, not a supported permission query.
|
||||
/// Open only the current user's database; never inspect or parse its contents.
|
||||
/// Missing files and ambiguous failures must not produce a permission prompt.
|
||||
fn protected_file_access() -> ProtectedFileAccess {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return ProtectedFileAccess::Unknown;
|
||||
};
|
||||
let path = home.join("Library/Application Support/com.apple.TCC/TCC.db");
|
||||
match OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(path)
|
||||
{
|
||||
Ok(file) => {
|
||||
let is_regular_file = file.metadata().is_ok_and(|metadata| metadata.is_file());
|
||||
drop(file);
|
||||
if is_regular_file {
|
||||
ProtectedFileAccess::Accessible
|
||||
} else {
|
||||
ProtectedFileAccess::Unknown
|
||||
}
|
||||
},
|
||||
// EACCES may reflect ordinary POSIX permissions or ACLs, so only
|
||||
// EPERM counts as protected-access denial. Neither is proof of the
|
||||
// setting's value; all other errors remain unknown.
|
||||
Err(error) if error.raw_os_error() == Some(libc::EPERM) => ProtectedFileAccess::Denied,
|
||||
Err(_) => ProtectedFileAccess::Unknown,
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
ProtectedFileAccess::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn show_guidance(ctx: &mut AppContext) {
|
||||
ctx.show_native_platform_modal(AlertDialogWithCallbacks::for_app(
|
||||
"Protected-file access is unavailable",
|
||||
"Full Disk Access is optional. Enabling it gives Galaxy, including its agents and \
|
||||
commands you run, broad access to protected files on this Mac.\n\n\
|
||||
To enable it, open System Settings > Privacy & Security > Full Disk Access and \
|
||||
turn on Galaxy. If Galaxy is missing, click + and select the installed Galaxy app. \
|
||||
macOS may ask you to quit and reopen Galaxy.\n\n\
|
||||
You can continue without this permission and reopen these settings from Galaxy's menu later.",
|
||||
vec![
|
||||
ModalButton::for_app("Open System Settings", open_settings),
|
||||
ModalButton::for_app("Not Now", |_| {}),
|
||||
],
|
||||
remember_dismissal,
|
||||
));
|
||||
}
|
||||
|
||||
/// Opens the macOS settings page without claiming or changing authorization.
|
||||
pub(crate) fn open_settings(ctx: &mut AppContext) {
|
||||
if cfg!(target_os = "macos") {
|
||||
ctx.open_url(SETTINGS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_dismissal(ctx: &mut AppContext) {
|
||||
if let Err(error) = ctx
|
||||
.private_user_preferences()
|
||||
.write_value(PROMPT_DISMISSED_KEY, true.to_string())
|
||||
{
|
||||
log::warn!("Could not save Full Disk Access guidance preference: {error}");
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ mod experiments;
|
||||
mod external_secrets;
|
||||
#[cfg(target_family = "wasm")]
|
||||
mod font_fallback;
|
||||
mod full_disk_access;
|
||||
pub mod galaxy_managed_paths_watcher;
|
||||
pub use galaxy_managed_paths_watcher as warp_managed_paths_watcher;
|
||||
mod global_resource_handles;
|
||||
@@ -2079,6 +2080,7 @@ pub(crate) fn initialize_app(
|
||||
|
||||
// AIDocumentModel subscribes to UpdateManager so that it can be notified when notebooks are created on the server.
|
||||
ctx.add_singleton_model(AIDocumentModel::new);
|
||||
ctx.add_singleton_model(ai::document::plan_archival::PlanArchivalModel::new);
|
||||
|
||||
// AgentConversationsModel subscribes to UpdateManager for RTC task updates.
|
||||
ctx.add_singleton_model(AgentConversationsModel::new);
|
||||
@@ -2825,6 +2827,10 @@ fn launch(ctx: &mut galaxyui::AppContext, app_state: Option<AppState>, launch_mo
|
||||
timer.mark_interval_end("WINDOWS_CREATED");
|
||||
});
|
||||
|
||||
if cfg!(target_os = "macos") && matches!(launch_mode, LaunchMode::App { .. }) {
|
||||
full_disk_access::prompt_on_startup(ctx);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
if matches!(launch_mode, LaunchMode::App { .. })
|
||||
&& PersistenceWriter::as_ref(ctx).sender().is_none()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -13,7 +14,7 @@ use crate::ai::mcp::templatable::{
|
||||
};
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
|
||||
use crate::cloud_object::{
|
||||
CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
|
||||
CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
|
||||
@@ -50,6 +51,7 @@ pub enum LocalObjectRepositoryEvent {
|
||||
Rules,
|
||||
ExecutionProfiles { id: Option<SyncId> },
|
||||
Notebooks { id: Option<SyncId> },
|
||||
NotebookContentEdited { id: SyncId },
|
||||
Workflows { id: Option<SyncId> },
|
||||
}
|
||||
|
||||
@@ -219,6 +221,95 @@ impl LocalObjectRepository {
|
||||
CloudModel::as_ref(app).get_folder(id).cloned()
|
||||
}
|
||||
|
||||
/// Local moves never require a server ID, connectivity, or a cloud ownership transfer.
|
||||
pub fn can_move_object_to_location(
|
||||
object_id: CloudObjectTypeAndId,
|
||||
new_location: CloudObjectLocation,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let Some(object) = cloud_model.get_by_uid(&object_id.uid()) else {
|
||||
return false;
|
||||
};
|
||||
if object.permissions().owner != local_owner() || object.is_trashed(cloud_model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match new_location {
|
||||
CloudObjectLocation::Space(space) => space == Space::Personal,
|
||||
CloudObjectLocation::Trash => true,
|
||||
CloudObjectLocation::Folder(folder_id) => {
|
||||
let mut ancestor_id = Some(folder_id);
|
||||
let mut visited = HashSet::new();
|
||||
while let Some(id) = ancestor_id {
|
||||
if object_id == CloudObjectTypeAndId::Folder(id) || !visited.insert(id) {
|
||||
return false;
|
||||
}
|
||||
let Some(folder) = cloud_model.get_folder(&id) else {
|
||||
return false;
|
||||
};
|
||||
if folder.permissions.owner != local_owner()
|
||||
|| folder.metadata.trashed_ts.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ancestor_id = folder.metadata.folder_id;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent a local object and persist its metadata without enqueuing server work.
|
||||
pub fn move_object_to_location(
|
||||
&mut self,
|
||||
object_id: CloudObjectTypeAndId,
|
||||
new_location: CloudObjectLocation,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if !Self::can_move_object_to_location(object_id, new_location, ctx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let event = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
let object = cloud_model.get_mut_by_uid(&object_id.uid())?;
|
||||
let from_folder = object.metadata().folder_id;
|
||||
let to_folder = match new_location {
|
||||
CloudObjectLocation::Space(_) => None,
|
||||
CloudObjectLocation::Folder(folder_id) => Some(folder_id),
|
||||
CloudObjectLocation::Trash => from_folder,
|
||||
};
|
||||
let event = if new_location == CloudObjectLocation::Trash {
|
||||
object.metadata_mut().trashed_ts = Some(ServerTimestamp::new(Utc::now()));
|
||||
CloudModelEvent::ObjectTrashed {
|
||||
type_and_id: object_id,
|
||||
source: UpdateSource::Local,
|
||||
}
|
||||
} else {
|
||||
if from_folder == to_folder {
|
||||
return None;
|
||||
}
|
||||
object.metadata_mut().folder_id = to_folder;
|
||||
CloudModelEvent::ObjectMoved {
|
||||
type_and_id: object_id,
|
||||
source: UpdateSource::Local,
|
||||
from_folder,
|
||||
to_folder,
|
||||
}
|
||||
};
|
||||
set_locally_saved_metadata(object.metadata_mut());
|
||||
let persistence_event = object.upsert_event();
|
||||
ctx.emit(event);
|
||||
ctx.notify();
|
||||
Some(persistence_event)
|
||||
});
|
||||
let Some(event) = event else {
|
||||
return false;
|
||||
};
|
||||
self.save(event);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn create_folder_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
@@ -383,7 +474,10 @@ impl LocalObjectRepository {
|
||||
let Some(notebook) = self.notebook(&id, ctx) else {
|
||||
return false;
|
||||
};
|
||||
self.update_notebook(
|
||||
if notebook.model().data == data {
|
||||
return true;
|
||||
}
|
||||
let updated = self.update_notebook(
|
||||
id,
|
||||
CloudNotebookModel {
|
||||
title: notebook.model().title.clone(),
|
||||
@@ -392,7 +486,11 @@ impl LocalObjectRepository {
|
||||
conversation_id: notebook.model().conversation_id.clone(),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
);
|
||||
if updated {
|
||||
ctx.emit(LocalObjectRepositoryEvent::NotebookContentEdited { id });
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub fn update_notebook_title(
|
||||
|
||||
@@ -172,6 +172,269 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notebook_content_edit_events_exclude_title_edits_and_unchanged_autosaves() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
id,
|
||||
None,
|
||||
CloudNotebookModel {
|
||||
title: "Plan".to_string(),
|
||||
data: "Initial content".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
receiver.recv().unwrap();
|
||||
let (edit_sender, edits) = sync_channel(10);
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&repository, move |_, event, _| {
|
||||
if let LocalObjectRepositoryEvent::NotebookContentEdited { id } = event {
|
||||
edit_sender.send(*id).unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_notebook_title(id, "Renamed plan".to_string(), ctx)
|
||||
}));
|
||||
receiver.recv().unwrap();
|
||||
assert!(edits.try_recv().is_err());
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_notebook_data(id, "Initial content".to_string(), ctx)
|
||||
}));
|
||||
assert!(receiver.try_recv().is_err());
|
||||
assert!(edits.try_recv().is_err());
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_notebook_data(id, "Changed content".to_string(), ctx)
|
||||
}));
|
||||
assert!(matches!(
|
||||
receiver.recv().unwrap(),
|
||||
ModelEvent::UpsertNotebook { notebook } if notebook.model().data == "Changed content"
|
||||
));
|
||||
assert_eq!(edits.try_recv().unwrap(), id);
|
||||
assert!(edits.try_recv().is_err());
|
||||
|
||||
assert!(!repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_notebook_data(
|
||||
SyncId::ClientId(ClientId::new()),
|
||||
"Missing notebook".to_string(),
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
assert!(receiver.try_recv().is_err());
|
||||
assert!(edits.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_local_plan_between_folders_and_root_is_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let plans_id = SyncId::ClientId(ClientId::new());
|
||||
let projects_id = SyncId::ClientId(ClientId::new());
|
||||
let notebook_id = SyncId::ClientId(ClientId::new());
|
||||
let document_id = crate::ai::document::ai_document_model::AIDocumentId::new();
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(plans_id, "Plans".to_string(), None, ctx);
|
||||
repository.create_folder_with_id(projects_id, "Projects".to_string(), None, ctx);
|
||||
repository.create_notebook_with_id(
|
||||
notebook_id,
|
||||
Some(plans_id),
|
||||
CloudNotebookModel {
|
||||
title: "Release plan".to_string(),
|
||||
data: "- [ ] Ship the release".to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
for _ in 0..3 {
|
||||
receiver.recv().unwrap();
|
||||
}
|
||||
|
||||
for (location, expected_parent) in [
|
||||
(CloudObjectLocation::Folder(projects_id), Some(projects_id)),
|
||||
(CloudObjectLocation::Space(Space::Personal), None),
|
||||
(CloudObjectLocation::Folder(plans_id), Some(plans_id)),
|
||||
] {
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(notebook_id),
|
||||
location,
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
let ModelEvent::UpsertNotebook { notebook } = receiver.recv().unwrap() else {
|
||||
panic!("expected the moved plan to be persisted locally");
|
||||
};
|
||||
assert_eq!(notebook.id, notebook_id);
|
||||
assert_eq!(notebook.metadata.folder_id, expected_parent);
|
||||
assert_eq!(notebook.model().ai_document_id, Some(document_id));
|
||||
assert_eq!(notebook.model().data, "- [ ] Ship the release");
|
||||
assert!(!notebook.metadata.has_pending_content_changes());
|
||||
assert!(!notebook.metadata.has_pending_online_only_change());
|
||||
assert_eq!(
|
||||
repository.read(&app, |repository, app| {
|
||||
repository
|
||||
.notebook(¬ebook_id, app)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id
|
||||
}),
|
||||
expected_parent,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_folder_moves_reject_cycles_and_missing_or_trashed_destinations() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let parent_id = SyncId::ClientId(ClientId::new());
|
||||
let child_id = SyncId::ClientId(ClientId::new());
|
||||
let trashed_id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(parent_id, "Plans".to_string(), None, ctx);
|
||||
repository.create_folder_with_id(
|
||||
child_id,
|
||||
"Nested".to_string(),
|
||||
Some(parent_id),
|
||||
ctx,
|
||||
);
|
||||
repository.create_folder_with_id(trashed_id, "Deleted".to_string(), None, ctx);
|
||||
repository.set_folder_trashed(trashed_id, true, ctx);
|
||||
});
|
||||
for _ in 0..4 {
|
||||
receiver.recv().unwrap();
|
||||
}
|
||||
|
||||
for location in [
|
||||
CloudObjectLocation::Folder(parent_id),
|
||||
CloudObjectLocation::Folder(child_id),
|
||||
CloudObjectLocation::Folder(trashed_id),
|
||||
CloudObjectLocation::Folder(SyncId::ClientId(ClientId::new())),
|
||||
CloudObjectLocation::Space(Space::Shared),
|
||||
] {
|
||||
assert!(!repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Folder(parent_id),
|
||||
location,
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
}
|
||||
assert!(receiver.try_recv().is_err());
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Folder(child_id),
|
||||
CloudObjectLocation::Space(Space::Personal),
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
assert!(matches!(
|
||||
receiver.recv().unwrap(),
|
||||
ModelEvent::UpsertFolder { folder }
|
||||
if folder.id == child_id && folder.metadata.folder_id.is_none()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_local_objects_to_trash_is_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let id = SyncId::ClientId(ClientId::new());
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_workflow_with_id(
|
||||
id,
|
||||
None,
|
||||
Workflow::new("Build", "cargo build"),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
receiver.recv().unwrap();
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Workflow(id),
|
||||
CloudObjectLocation::Trash,
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
assert!(matches!(
|
||||
receiver.recv().unwrap(),
|
||||
ModelEvent::UpsertWorkflow { workflow }
|
||||
if workflow.id == id && workflow.metadata.trashed_ts.is_some()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_moves_do_not_transfer_objects_across_ownership_boundaries() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let local_id = SyncId::ClientId(ClientId::new());
|
||||
let cloud_id = ClientId::new();
|
||||
let cloud_folder_id = ClientId::new();
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_notebook_with_id(local_id, None, CloudNotebookModel::default(), ctx);
|
||||
});
|
||||
receiver.recv().unwrap();
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, ctx| {
|
||||
cloud_model.create_object(
|
||||
SyncId::ClientId(cloud_id),
|
||||
CloudNotebook::new_local(
|
||||
CloudNotebookModel::default(),
|
||||
Owner::mock_current_user(),
|
||||
None,
|
||||
cloud_id,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
cloud_model.create_object(
|
||||
SyncId::ClientId(cloud_folder_id),
|
||||
CloudFolder::new_local(
|
||||
CloudFolderModel::new("Shared", false),
|
||||
Owner::mock_current_user(),
|
||||
None,
|
||||
cloud_folder_id,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
assert!(!repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(local_id),
|
||||
CloudObjectLocation::Folder(SyncId::ClientId(cloud_folder_id)),
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
assert!(!repository.update(&mut app, |repository, ctx| {
|
||||
repository.move_object_to_location(
|
||||
CloudObjectTypeAndId::Notebook(SyncId::ClientId(cloud_id)),
|
||||
CloudObjectLocation::Space(Space::Personal),
|
||||
ctx,
|
||||
)
|
||||
}));
|
||||
assert!(receiver.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -84,15 +84,15 @@ pub(super) fn validate_local_harness_shell(shell_type: Option<ShellType>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS: &str = r#"You are a local Claude Code child agent launched by a lead agent in Warp.
|
||||
const LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS: &str = r#"You are a local Claude Code child agent launched by a lead agent in Galaxy.
|
||||
|
||||
Coordinate with the lead agent through the Oz CLI messaging environment:
|
||||
Coordinate with the lead agent through Galaxy's local CLI messaging environment:
|
||||
- Your run id is in OZ_RUN_ID.
|
||||
- The lead agent id is in OZ_PARENT_RUN_ID.
|
||||
- The Oz CLI command is in OZ_CLI.
|
||||
- The Galaxy CLI command is in OZ_CLI (the compatibility environment variable name).
|
||||
|
||||
If OZ_CLI, OZ_RUN_ID, or OZ_PARENT_RUN_ID is missing, report that blocker in your final response.
|
||||
Do not use Claude Code Agent or SendMessage tools to contact the lead agent; use the Oz CLI commands below.
|
||||
Do not use Claude Code Agent or SendMessage tools to contact the lead agent; use the Galaxy CLI commands below.
|
||||
Do not ask to inspect help before messaging. The command shapes below are complete.
|
||||
|
||||
Send a message to the lead agent at start, when blocked, and when complete:
|
||||
|
||||
@@ -53,7 +53,7 @@ const MODAL_CONTENT_FONT_SIZE: f32 = 14.;
|
||||
const CHECKBOX_SIZE: f32 = 16.;
|
||||
|
||||
const MODAL_TITLE: &str = "Edit prompt";
|
||||
const WARP_PROMPT_SECTION_HEADER: &str = "Warp terminal prompt";
|
||||
const WARP_PROMPT_SECTION_HEADER: &str = "Galaxy terminal prompt";
|
||||
const SHELL_PROMPT_SECTION_HEADER: &str = "Shell prompt (PS1)";
|
||||
const RESTORE_DEFAULT_BUTTON: &str = "Restore default";
|
||||
|
||||
|
||||
@@ -853,6 +853,7 @@ impl View {
|
||||
conversation_id,
|
||||
terminal_view_id,
|
||||
restore_layout: None,
|
||||
local_only: false,
|
||||
});
|
||||
send_telemetry_from_app_ctx!(TelemetryEvent::SelectNavigationPaletteItem, ctx);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ use crate::ui_components::icons::Icon as UIIcon;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use crate::workflows::{AIWorkflowOrigin, WorkflowSource, WorkflowType};
|
||||
|
||||
const OPEN_WARP_AI_ITEM_BODY_TEXT: &str = "Ask Warp AI for command suggestions";
|
||||
const TRANSLATE_WITH_WARP_AI_ITEM_BODY_TEXT: &str = "Translate into shell command using Warp AI";
|
||||
const OPEN_WARP_AI_ITEM_BODY_TEXT: &str = "Ask Galaxy Agent for command suggestions";
|
||||
const TRANSLATE_WITH_WARP_AI_ITEM_BODY_TEXT: &str =
|
||||
"Translate into a shell command using Galaxy Agent";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WarpAISearchItem {
|
||||
@@ -133,7 +134,7 @@ impl SearchItem for WarpAISearchItem {
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Warp AI: {}", self.item_body_text())
|
||||
format!("Galaxy Agent: {}", self.item_body_text())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ pub const CREATE_DOCKER_SANDBOX: StaticCommand = StaticCommand {
|
||||
|
||||
pub static CREATE_NEW_PROJECT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
|
||||
name: "/create-new-project",
|
||||
description: "Have Oz walk you through creating a new coding project",
|
||||
description: "Have Galaxy walk you through creating a new coding project",
|
||||
icon_path: "bundled/svg/plus.svg",
|
||||
availability: Availability::LOCAL | Availability::AI_ENABLED,
|
||||
auto_enter_ai_mode: true,
|
||||
@@ -79,7 +79,7 @@ pub static CREATE_NEW_PROJECT: LazyLock<StaticCommand> = LazyLock::new(|| Static
|
||||
|
||||
pub static EDIT_SKILL: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
|
||||
name: "/open-skill",
|
||||
description: "Open a skill's markdown file in Warp's built-in editor",
|
||||
description: "Open a skill's markdown file in Galaxy's built-in editor",
|
||||
icon_path: "bundled/svg/file-code-02.svg",
|
||||
availability: Availability::AI_ENABLED,
|
||||
auto_enter_ai_mode: false,
|
||||
@@ -119,7 +119,7 @@ pub const ADD_RULE: StaticCommand = StaticCommand {
|
||||
|
||||
pub static EDIT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
|
||||
name: "/open-file",
|
||||
description: "Open a file in Warp's code editor",
|
||||
description: "Open a file in Galaxy's code editor",
|
||||
icon_path: "bundled/svg/file-code-02.svg",
|
||||
availability: Availability::LOCAL,
|
||||
auto_enter_ai_mode: false,
|
||||
|
||||
@@ -834,10 +834,34 @@ pub struct BedrockModelConfig {
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Whether the model supports image/vision input.")]
|
||||
pub vision_supported: bool,
|
||||
#[serde(default)]
|
||||
pub invocation_model_id: String,
|
||||
#[serde(default)]
|
||||
pub provider_name: String,
|
||||
#[serde(default)]
|
||||
pub response_streaming_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub runtime_supported: Option<bool>,
|
||||
}
|
||||
|
||||
impl settings_value::SettingsValue for BedrockModelConfig {}
|
||||
|
||||
impl Default for BedrockModelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
access_verified: false,
|
||||
model_id: String::new(),
|
||||
display_name: String::new(),
|
||||
vision_supported: false,
|
||||
invocation_model_id: String::new(),
|
||||
provider_name: String::new(),
|
||||
response_streaming_supported: None,
|
||||
runtime_supported: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_context_size() -> u32 {
|
||||
200_000
|
||||
}
|
||||
|
||||
@@ -32,6 +32,22 @@ fn create_test_request_limit_info(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bedrock_model_settings_default_live_capabilities_without_authorization() {
|
||||
let model: BedrockModelConfig = serde_json::from_value(serde_json::json!({
|
||||
"model_id": "future.new-model",
|
||||
"display_name": "Saved model",
|
||||
"enabled": false,
|
||||
"vision_supported": true
|
||||
})).unwrap();
|
||||
assert_eq!(model.invocation_model_id, "");
|
||||
assert_eq!(model.provider_name, "");
|
||||
assert_eq!(model.response_streaming_supported, None);
|
||||
assert_eq!(model.runtime_supported, None);
|
||||
assert!(!model.access_verified);
|
||||
assert!(!model.enabled);
|
||||
}
|
||||
|
||||
fn add_ai_enablement_dependencies_for_test(app: &mut App) {
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
@@ -446,6 +462,7 @@ fn orchestration_is_enabled_when_ai_is_enabled() {
|
||||
model_id: "test-model".to_string(),
|
||||
display_name: "Test model".to_string(),
|
||||
vision_supported: false,
|
||||
..Default::default()
|
||||
}],
|
||||
ctx,
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ use strum::IntoEnumIterator;
|
||||
use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent};
|
||||
use super::provider_setup_view::{
|
||||
model_catalog_table, AcpProviderDraft, BedrockProviderDraft, ProviderSetupProviderType,
|
||||
ProviderSetupView, ProviderSetupViewEvent,
|
||||
ProviderSetupView, ProviderSetupViewAction, ProviderSetupViewEvent,
|
||||
};
|
||||
use super::settings_page::{
|
||||
build_sub_header, build_toggle_element, render_body_item_label,
|
||||
@@ -2720,6 +2720,7 @@ pub enum AISettingsPageAction {
|
||||
},
|
||||
RemoveOpenAIProvider(usize),
|
||||
EditBedrockProvider,
|
||||
RefreshBedrockModels,
|
||||
RemoveBedrockProvider,
|
||||
RemoveAcpProvider(String),
|
||||
ToggleFileBasedMcp,
|
||||
@@ -3457,6 +3458,12 @@ impl TypedActionView for AISettingsPageView {
|
||||
AISettingsPageAction::EditBedrockProvider => {
|
||||
self.begin_inline_bedrock_setup(ctx);
|
||||
}
|
||||
AISettingsPageAction::RefreshBedrockModels => {
|
||||
self.begin_inline_bedrock_setup(ctx);
|
||||
self.provider_setup_body.update(ctx, |body, ctx| {
|
||||
body.handle_action(&ProviderSetupViewAction::Next, ctx);
|
||||
});
|
||||
}
|
||||
AISettingsPageAction::RemoveBedrockProvider => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.bedrock_enabled.set_value(false, ctx));
|
||||
@@ -6641,6 +6648,7 @@ struct ProviderSettingsWidget {
|
||||
bedrock_add_button: ViewHandle<ActionButton>,
|
||||
acp_add_button: ViewHandle<ActionButton>,
|
||||
bedrock_edit_button: ViewHandle<ActionButton>,
|
||||
bedrock_refresh_button: ViewHandle<ActionButton>,
|
||||
bedrock_remove_button: ViewHandle<ActionButton>,
|
||||
provider_cards: Vec<OpenAIProviderCardState>,
|
||||
acp_provider_cards: Vec<AcpProviderCardState>,
|
||||
@@ -6749,6 +6757,11 @@ impl ProviderSettingsWidget {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::EditBedrockProvider);
|
||||
})
|
||||
});
|
||||
let bedrock_refresh_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Refresh models", SecondaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::RefreshBedrockModels);
|
||||
})
|
||||
});
|
||||
let bedrock_remove_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Delete", DangerSecondaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider);
|
||||
@@ -6766,6 +6779,7 @@ impl ProviderSettingsWidget {
|
||||
bedrock_add_button,
|
||||
acp_add_button,
|
||||
bedrock_edit_button,
|
||||
bedrock_refresh_button,
|
||||
bedrock_remove_button,
|
||||
provider_cards,
|
||||
acp_provider_cards,
|
||||
@@ -7275,6 +7289,7 @@ impl ProviderSettingsWidget {
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_child(ChildView::new(&self.bedrock_refresh_button).finish())
|
||||
.with_child(ChildView::new(&self.bedrock_edit_button).finish())
|
||||
.with_child(ChildView::new(&self.bedrock_remove_button).finish())
|
||||
.finish(),
|
||||
|
||||
@@ -253,7 +253,7 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
|
||||
// Add command palette entry for toggling between Warp and Classic input modes
|
||||
app.register_fixed_bindings(vec![FixedBinding::empty(
|
||||
"Toggle Input Mode (Warp/Classic)".to_string(),
|
||||
"Toggle Input Mode (Galaxy/Classic)".to_string(),
|
||||
builder(SettingsAction::AppearancePageToggle(
|
||||
AppearancePageAction::ToggleInputMode,
|
||||
)),
|
||||
@@ -1643,7 +1643,7 @@ impl AppearanceSettingsPageView {
|
||||
|
||||
fn input_mode_dropdown_item_label(val: InputMode) -> &'static str {
|
||||
match val {
|
||||
InputMode::PinnedToBottom => "Pin to the bottom (Warp mode)",
|
||||
InputMode::PinnedToBottom => "Pin to the bottom (Galaxy mode)",
|
||||
InputMode::PinnedToTop => "Pin to the top (Reverse mode)",
|
||||
InputMode::Waterfall => "Start at the top (Classic mode)",
|
||||
}
|
||||
@@ -3061,7 +3061,7 @@ impl SettingsWidget for CustomAppIconWidget {
|
||||
);
|
||||
|
||||
let show_dock_icon_toggle = render_body_item::<AppearancePageAction>(
|
||||
"Show Warp in Dock".into(),
|
||||
"Show Galaxy in Dock".into(),
|
||||
None,
|
||||
LocalOnlyIconState::for_setting(
|
||||
ShowDockIconState::storage_key(),
|
||||
@@ -3101,7 +3101,7 @@ impl SettingsWidget for CustomAppIconWidget {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(
|
||||
"You may need to restart Warp for MacOS to apply the preferred icon style.",
|
||||
"You may need to restart Galaxy for macOS to apply the preferred icon style.",
|
||||
true,
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
@@ -3590,7 +3590,7 @@ impl SettingsWidget for InputTypeWidget {
|
||||
.radio_buttons(
|
||||
self.radio_buttons_states.clone(),
|
||||
vec![
|
||||
RadioButtonItem::text("Warp"),
|
||||
RadioButtonItem::text("Galaxy"),
|
||||
RadioButtonItem::text("Shell (PS1)"),
|
||||
],
|
||||
view.input_type_radio_state.clone(),
|
||||
@@ -5093,7 +5093,7 @@ impl SettingsWidget for UseLatestUserPromptAsConversationTitleInTabNamesWidget {
|
||||
})
|
||||
.finish(),
|
||||
Some(
|
||||
"Show the latest user prompt instead of the generated conversation title for Oz and third-party agent sessions in vertical tabs."
|
||||
"Show the latest user prompt instead of the generated conversation title for Galaxy and third-party agent sessions in vertical tabs."
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -303,7 +303,7 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
];
|
||||
|
||||
toggle_binding_pairs.push(ToggleSettingActionPair::new(
|
||||
"reuse existing SSH ControlMaster in the Warp SSH wrapper",
|
||||
"reuse existing SSH ControlMaster in the Galaxy SSH wrapper",
|
||||
builder(SettingsAction::FeaturesPageToggle(
|
||||
FeaturesPageAction::ToggleSshReuseControlMaster,
|
||||
)),
|
||||
@@ -714,7 +714,7 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
|
||||
if DefaultTerminal::can_warp_become_default() {
|
||||
app.register_fixed_bindings([FixedBinding::empty(
|
||||
"Make Warp the default terminal",
|
||||
"Make Galaxy the default terminal",
|
||||
builder(SettingsAction::FeaturesPageToggle(
|
||||
FeaturesPageAction::MakeWarpDefaultTerminal,
|
||||
)),
|
||||
@@ -4822,9 +4822,9 @@ impl SettingsWidget for LoginItemWidget {
|
||||
let general_settings = GeneralSettings::as_ref(app);
|
||||
let ui_builder = appearance.ui_builder();
|
||||
#[cfg(target_os = "macos")]
|
||||
let label = "Start Warp at login (requires macOS 13+)";
|
||||
let label = "Start Galaxy at login (requires macOS 13+)";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let label = "Start Warp at login";
|
||||
let label = "Start Galaxy at login";
|
||||
render_body_item::<FeaturesPageAction>(
|
||||
label.into(),
|
||||
None,
|
||||
@@ -5101,7 +5101,7 @@ impl SettingsWidget for DefaultTerminalWidget {
|
||||
let default_terminal = DefaultTerminal::as_ref(app);
|
||||
if default_terminal.is_warp_default() {
|
||||
ui_builder
|
||||
.wrappable_text("Warp is the default terminal", true)
|
||||
.wrappable_text("Galaxy is the default terminal", true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().disabled_ui_text_color().into()),
|
||||
margin: Some(Coords::default().bottom(16.)),
|
||||
@@ -5112,7 +5112,7 @@ impl SettingsWidget for DefaultTerminalWidget {
|
||||
} else {
|
||||
ui_builder
|
||||
.link(
|
||||
"Make Warp the default terminal".to_string(),
|
||||
"Make Galaxy the default terminal".to_string(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(FeaturesPageAction::MakeWarpDefaultTerminal);
|
||||
@@ -5207,7 +5207,7 @@ impl SettingsWidget for DesktopNotificationsWidget {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let mut column = Flex::column();
|
||||
column.add_child(render_body_item::<FeaturesPageAction>(
|
||||
"Receive desktop notifications from Warp".into(),
|
||||
"Receive desktop notifications from Galaxy".into(),
|
||||
Some(AdditionalInfo {
|
||||
mouse_state: self.additional_info_link.clone(),
|
||||
on_click_action: Some(FeaturesPageAction::OpenUrl(NOTIFICATIONS_DOCS_URL.into())),
|
||||
@@ -7552,7 +7552,7 @@ impl SettingsWidget for WindowSystemWidget {
|
||||
may be blurry if your Wayland compositor is using fraction scaling (ex: 125%)."
|
||||
.to_string();
|
||||
if view.force_x11_changed {
|
||||
secondary_text.push_str("\n\nRestart Warp for changes to take effect.");
|
||||
secondary_text.push_str("\n\nRestart Galaxy for changes to take effect.");
|
||||
}
|
||||
let warp_theme = appearance.theme();
|
||||
children.add_child(
|
||||
|
||||
@@ -220,6 +220,7 @@ pub enum ProviderSetupViewAction {
|
||||
ToggleModel(usize),
|
||||
CycleModelCapability(usize, CapabilityKey),
|
||||
ConnectChatGPT,
|
||||
CancelChatGPTSignIn,
|
||||
ConnectClaude,
|
||||
SignOutClaude,
|
||||
CancelClaudeSignIn,
|
||||
@@ -687,10 +688,8 @@ impl ProviderSetupView {
|
||||
self.draft_bedrock = draft;
|
||||
self.draft_models.clear();
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.bedrock_region_request += 1;
|
||||
self.sync_editors(ctx);
|
||||
if self.draft_bedrock.region.is_empty() {
|
||||
self.load_bedrock_profile_region(ctx);
|
||||
}
|
||||
self.sync_provider_type_buttons(ctx);
|
||||
self.sync_bedrock_auth_buttons(ctx);
|
||||
self.sync_model_switches(ctx);
|
||||
@@ -1130,8 +1129,23 @@ impl ProviderSetupView {
|
||||
}
|
||||
}
|
||||
|
||||
fn bedrock_client_config(&self) -> crate::ai::provider::client::BedrockClientConfig {
|
||||
crate::ai::provider::client::BedrockClientConfig {
|
||||
auth_method: self.draft_bedrock.auth_method,
|
||||
profile: self.draft_bedrock.profile.clone(),
|
||||
region: self.draft_bedrock.region.clone(),
|
||||
access_key_id: self.draft_bedrock.access_key_id.clone(),
|
||||
secret_access_key: self.draft_bedrock.secret_access_key.clone(),
|
||||
session_token: None,
|
||||
credentials_expiry: None,
|
||||
invocation_model_id: None,
|
||||
cross_region_inference: self.draft_bedrock.cross_region_inference,
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.cancel_acp_setup();
|
||||
self.bedrock_region_request += 1;
|
||||
self.step = ProviderSetupStep::Discover;
|
||||
self.discovery_state = DiscoveryState::Loading;
|
||||
self.acp_setup_status.clear();
|
||||
@@ -1145,33 +1159,45 @@ impl ProviderSetupView {
|
||||
return;
|
||||
}
|
||||
ProviderSetupProviderType::Bedrock => {
|
||||
let config = crate::ai::provider::client::BedrockClientConfig {
|
||||
auth_method: self.draft_bedrock.auth_method,
|
||||
profile: self.draft_bedrock.profile.clone(),
|
||||
region: self.draft_bedrock.region.clone(),
|
||||
access_key_id: self.draft_bedrock.access_key_id.clone(),
|
||||
secret_access_key: self.draft_bedrock.secret_access_key.clone(),
|
||||
session_token: None,
|
||||
cross_region_inference: self.draft_bedrock.cross_region_inference,
|
||||
};
|
||||
let config = self.bedrock_client_config();
|
||||
let requested_config = config.clone();
|
||||
let generation = self.acp_discovery_generation;
|
||||
let verification_generation = LLMPreferences::handle(ctx)
|
||||
.update(ctx, |preferences, _| preferences.begin_bedrock_verification());
|
||||
ctx.spawn(
|
||||
async move {
|
||||
crate::ai::provider::discovery::discover_available_models(config).await
|
||||
},
|
||||
move |me, result, ctx| match result {
|
||||
move |me, result, ctx| {
|
||||
if generation != me.acp_discovery_generation
|
||||
|| me.provider_type != ProviderSetupProviderType::Bedrock
|
||||
|| me.step != ProviderSetupStep::Discover
|
||||
|| me.bedrock_client_config() != requested_config
|
||||
{
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(discovery) => {
|
||||
for record in discovery.records {
|
||||
crate::ai::remote_logging::log_view_event(ctx, record);
|
||||
}
|
||||
let models = discovery.models;
|
||||
LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| {
|
||||
let accepted = LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| {
|
||||
preferences.record_bedrock_verification(
|
||||
verification_generation,
|
||||
requested_config,
|
||||
&models,
|
||||
ctx,
|
||||
);
|
||||
)
|
||||
});
|
||||
if !accepted {
|
||||
me.discovery_state = DiscoveryState::Failed(
|
||||
"A newer Bedrock connection test superseded this result. Retry the test.".to_owned(),
|
||||
);
|
||||
me.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
me.draft_bedrock.models =
|
||||
crate::ai::provider::discovery::preserve_model_toggles(
|
||||
&me.draft_bedrock.models,
|
||||
@@ -1189,6 +1215,7 @@ impl ProviderSetupView {
|
||||
me.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
@@ -1564,10 +1591,10 @@ impl ProviderSetupView {
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
state,
|
||||
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_)
|
||||
) {
|
||||
let pending = matches!(state,
|
||||
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||
);
|
||||
{
|
||||
children.push(
|
||||
appearance
|
||||
.ui_builder()
|
||||
@@ -1575,10 +1602,21 @@ impl ProviderSetupView {
|
||||
ButtonVariant::Secondary,
|
||||
self.chatgpt_connect_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Connect ChatGPT".to_owned())
|
||||
.with_text_label(if pending {
|
||||
"Cancel sign-in"
|
||||
} else if matches!(state, ChatGPTAuthState::Connected) {
|
||||
"Reconnect ChatGPT"
|
||||
} else {
|
||||
"Connect ChatGPT"
|
||||
}.to_owned())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT);
|
||||
.on_click(move |ctx, _, _| {
|
||||
let action = if pending {
|
||||
ProviderSetupViewAction::CancelChatGPTSignIn
|
||||
} else {
|
||||
ProviderSetupViewAction::ConnectChatGPT
|
||||
};
|
||||
ctx.dispatch_typed_action(action);
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
@@ -1835,7 +1873,7 @@ impl ProviderSetupView {
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
|
||||
fn render_discovery(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn galaxyui::Element> {
|
||||
let (message, color) = match &self.discovery_state {
|
||||
DiscoveryState::Idle | DiscoveryState::Loading => (
|
||||
if self.provider_type == ProviderSetupProviderType::Acp
|
||||
@@ -1849,7 +1887,7 @@ impl ProviderSetupView {
|
||||
),
|
||||
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
|
||||
};
|
||||
Flex::column()
|
||||
let mut column = Flex::column()
|
||||
.with_spacing(12.)
|
||||
.with_child(
|
||||
Text::new(
|
||||
@@ -1887,8 +1925,13 @@ impl ProviderSetupView {
|
||||
.with_color(color)
|
||||
.soft_wrap(true)
|
||||
.finish()
|
||||
})
|
||||
.finish()
|
||||
});
|
||||
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription
|
||||
&& matches!(self.discovery_state, DiscoveryState::Failed(_))
|
||||
{
|
||||
column = column.with_child(self.render_chatgpt_auth(appearance, app));
|
||||
}
|
||||
column.finish()
|
||||
}
|
||||
|
||||
fn render_model_table(
|
||||
@@ -2442,7 +2485,7 @@ impl View for ProviderSetupView {
|
||||
let content = match self.step {
|
||||
ProviderSetupStep::ProviderType => self.render_provider_type(appearance),
|
||||
ProviderSetupStep::Configure => self.render_configure(appearance, app),
|
||||
ProviderSetupStep::Discover => self.render_discovery(appearance),
|
||||
ProviderSetupStep::Discover => self.render_discovery(appearance, app),
|
||||
ProviderSetupStep::Models => self.render_models(appearance),
|
||||
};
|
||||
Flex::column()
|
||||
@@ -2462,6 +2505,10 @@ impl TypedActionView for ProviderSetupView {
|
||||
match action {
|
||||
ProviderSetupViewAction::SelectProvider(kind) => {
|
||||
if self.provider_type != *kind {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||
}
|
||||
self.cancel_claude_signin();
|
||||
self.draft_api_key = None;
|
||||
self.claude_credentials_available = false;
|
||||
@@ -2562,6 +2609,10 @@ impl TypedActionView for ProviderSetupView {
|
||||
ProviderSetupViewAction::Cancel => {
|
||||
self.cancel_acp_setup();
|
||||
self.cancel_claude_signin();
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||
}
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
ctx.emit(ProviderSetupViewEvent::Close);
|
||||
}
|
||||
@@ -2607,6 +2658,10 @@ impl TypedActionView for ProviderSetupView {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||
}
|
||||
ProviderSetupViewAction::CancelChatGPTSignIn => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
|
||||
}
|
||||
ProviderSetupViewAction::ConnectClaude => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
self.connect_claude(ctx);
|
||||
|
||||
@@ -35,10 +35,10 @@ const OPEN_BUTTON_HEIGHT: f32 = 32.;
|
||||
const ALERT_ACTION_BUTTON_HEIGHT: f32 = 24.;
|
||||
/// Size of the leading icons (search-sm, code-02, alert-circle, oz).
|
||||
const FOOTER_ICON_SIZE: f32 = 16.;
|
||||
/// Size of the Oz brand mark inside the "Fix with Oz" button. Matches the
|
||||
/// Size of the agent icon inside the "Fix with Galaxy Agent" button. Matches the
|
||||
/// Figma spec and the workspace banner's secondary-button icon sizing.
|
||||
const ALERT_OZ_ICON_SIZE: f32 = 14.;
|
||||
/// Horizontal padding inside the "Open file" / "Fix with Oz" action buttons.
|
||||
/// Horizontal padding inside the "Open file" / "Fix with Galaxy Agent" action buttons.
|
||||
/// Matches the workspace banner's secondary button pad.
|
||||
const ALERT_BUTTON_HORIZONTAL_PADDING: f32 = 8.;
|
||||
/// Spacing between the two action buttons when they fit on one row.
|
||||
@@ -250,7 +250,7 @@ pub fn render_settings_error_alert(
|
||||
ui_font_family,
|
||||
text_color,
|
||||
mouse_states.alert_fix_with_oz_button.clone(),
|
||||
"Fix with Oz",
|
||||
"Fix with Galaxy Agent",
|
||||
Some(Icon::Oz),
|
||||
/*bordered=*/ false,
|
||||
WorkspaceAction::FixSettingsWithOz { error_description },
|
||||
|
||||
@@ -193,11 +193,11 @@ impl CliAgentPluginManager for CodexPluginManager {
|
||||
}
|
||||
|
||||
fn install_success_message(&self) -> &'static str {
|
||||
"Warp plugin installed. Please restart Codex to activate."
|
||||
"Galaxy plugin installed. Please restart Codex to activate."
|
||||
}
|
||||
|
||||
fn update_success_message(&self) -> &'static str {
|
||||
"Warp plugin updated. Please restart Codex to activate."
|
||||
"Galaxy plugin updated. Please restart Codex to activate."
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
@@ -273,17 +273,17 @@ impl CliAgentPluginManager for CodexPluginManager {
|
||||
|
||||
static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> =
|
||||
LazyLock::new(|| PluginInstructions {
|
||||
title: "Install Warp Plugin for Codex",
|
||||
title: "Install Galaxy notification plugin for Codex",
|
||||
subtitle: "Run the following commands, then restart Codex.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Add the Warp plugin marketplace repository",
|
||||
description: "Add the notification plugin marketplace repository",
|
||||
command: "codex plugin marketplace add warpdotdev/codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Install the Warp plugin",
|
||||
description: "Install the notification plugin",
|
||||
command: "codex plugin add warp@codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
@@ -294,8 +294,8 @@ static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> =
|
||||
|
||||
static NATIVE_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Enable Warp Notifications for Codex",
|
||||
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.",
|
||||
title: "Enable Galaxy notifications for Codex",
|
||||
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Galaxy can display them while you work.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Update Codex to the latest version.",
|
||||
@@ -323,7 +323,7 @@ static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plugi
|
||||
|
||||
static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Update Warp Plugin for Codex",
|
||||
title: "Update Galaxy notification plugin for Codex",
|
||||
subtitle: "Run the following commands, then restart Codex.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
@@ -333,7 +333,7 @@ static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Reinstall the Warp plugin",
|
||||
description: "Reinstall the notification plugin",
|
||||
command: "codex plugin add warp@codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
|
||||
@@ -21,7 +21,7 @@ fn can_auto_install_is_false_without_codex_plugin() {
|
||||
fn install_instructions_are_native_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let instructions = CodexPluginManager::new(None, None, None).install_instructions();
|
||||
assert_eq!(instructions.title, "Enable Warp Notifications for Codex");
|
||||
assert_eq!(instructions.title, "Enable Galaxy notifications for Codex");
|
||||
assert_eq!(
|
||||
instructions.steps[1].command,
|
||||
"[tui]\nnotification_condition = \"always\""
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
const AUTO_BEDROCK_TOOLTIP: &str = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference.";
|
||||
const AUTO_BEDROCK_TOOLTIP: &str = "Galaxy uses Bedrock when the model Auto selects supports it. Other models use their configured provider.";
|
||||
|
||||
fn model_picker_label(llm: &LLMInfo) -> String {
|
||||
if llm.has_reasoning_level() && !is_custom_router_id(llm.id.as_str()) {
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn wormhole_description(
|
||||
|
||||
let description = FormattedText::new(vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Bring Galaxy's features to your remote session: blocks, full text editing, completions, Oz, and more."
|
||||
"Bring Galaxy's features to your remote session: blocks, full text editing, completions, Galaxy Agent, and more."
|
||||
),
|
||||
])]);
|
||||
wormhole::render::build_description_row(description, theme, appearance, hyperlink_index.clone())
|
||||
|
||||
+54
-110
@@ -29,6 +29,8 @@ use warp_util::remote_path::RemotePath;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
use crate::ai::block_context::BlockContext;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::provider::authentication::{BedrockAuthConnection, BedrockAuthentication};
|
||||
use crate::global_resource_handles::GlobalResourceHandlesProvider;
|
||||
pub(crate) mod docker_sandbox;
|
||||
mod link_detection;
|
||||
@@ -7806,24 +7808,24 @@ impl TerminalView {
|
||||
.get_pending_action(app)
|
||||
.map(|action| match &action.action {
|
||||
AIAgentActionType::RequestCommandOutput { command, .. } => {
|
||||
format!("Oz needs your permission to run `{command}`")
|
||||
format!("Galaxy needs your permission to run `{command}`")
|
||||
}
|
||||
AIAgentActionType::ReadFiles(..) => {
|
||||
"Oz needs your permission to read files".to_string()
|
||||
"Galaxy needs your permission to read files".to_string()
|
||||
}
|
||||
AIAgentActionType::SearchCodebase(..) => {
|
||||
"Oz needs your permission to search your codebase".to_string()
|
||||
"Galaxy needs your permission to search your codebase".to_string()
|
||||
}
|
||||
AIAgentActionType::RequestFileEdits { .. } => {
|
||||
"Oz needs your permission to edit a file".to_string()
|
||||
"Galaxy needs your permission to edit a file".to_string()
|
||||
}
|
||||
AIAgentActionType::WriteToLongRunningShellCommand { .. } => {
|
||||
"Oz needs your permission to interact with a running shell command"
|
||||
"Galaxy needs your permission to interact with a running shell command"
|
||||
.to_string()
|
||||
}
|
||||
_ => "Oz needs your confirmation to continue".to_string(),
|
||||
_ => "Galaxy needs your confirmation to continue".to_string(),
|
||||
})
|
||||
.unwrap_or("Oz needs your confirmation to continue".to_string());
|
||||
.unwrap_or("Galaxy needs your confirmation to continue".to_string());
|
||||
return Some(AIBlockNotificationSummary {
|
||||
success: false,
|
||||
title,
|
||||
@@ -10917,89 +10919,55 @@ impl TerminalView {
|
||||
self.remove_aws_bedrock_login_banner(ctx);
|
||||
}
|
||||
|
||||
/// Runs the AWS login command configured in settings to refresh Bedrock credentials.
|
||||
/// Doing this in PTY vs just a subprocess allows the user to see any output/errors
|
||||
/// from the command directly in the terminal. Also, `aws login` commands may require
|
||||
/// user interaction (e.g. "do you want to override X profile? y/n" is common)
|
||||
/// Refreshes the configured AWS connection and resumes only its originating conversation.
|
||||
fn run_aws_login_command(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let settings_command = AISettings::as_ref(ctx)
|
||||
.bedrock_auth_refresh_command
|
||||
.value()
|
||||
.clone();
|
||||
|
||||
// Use the configured command, but if it's the bare default ("aws sso login")
|
||||
// prefer the external config's auth_refresh_command which includes --profile.
|
||||
let login_command = if settings_command == "aws sso login" {
|
||||
use crate::ai::provider::external_config::ExternalBedrockConfig;
|
||||
let external = ExternalBedrockConfig::load();
|
||||
external.auth_refresh_command.unwrap_or(settings_command)
|
||||
let conversation_id = if FeatureFlag::AgentView.is_enabled() {
|
||||
self.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
} else {
|
||||
settings_command
|
||||
BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(self.id())
|
||||
};
|
||||
self.recover_aws_authentication(conversation_id, true, ctx);
|
||||
}
|
||||
|
||||
if login_command.is_empty() {
|
||||
log::warn!("AWS login command is not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("[bedrock] Running AWS login command: {login_command}");
|
||||
|
||||
// Spawn the login command as an async subprocess so it can open the browser
|
||||
// for SSO authentication. Once it completes, refresh credentials and resume
|
||||
// the conversation.
|
||||
fn recover_aws_authentication(
|
||||
&mut self,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
manual: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let exchange_id = conversation_id
|
||||
.and_then(|id| Self::thread_start_exchange_id(&id, ctx));
|
||||
let connection = BedrockAuthConnection::current(ctx);
|
||||
let conversation_key = conversation_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| format!("login-only-{}", self.view_id));
|
||||
let exchange_key = exchange_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let recovery = BedrockAuthentication::handle(ctx).update(ctx, |authentication, ctx| {
|
||||
authentication.recover(&conversation_key, &exchange_key, manual, ctx)
|
||||
});
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let parts: Vec<&str> = login_command.split_whitespace().collect();
|
||||
let Some((cmd, args)) = parts.split_first() else {
|
||||
return Err("Empty login command".to_string());
|
||||
};
|
||||
let result = tokio::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.await;
|
||||
match result {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(format!("AWS login command exited with status: {status}")),
|
||||
Err(e) => Err(format!("Failed to spawn AWS login command: {e}")),
|
||||
}
|
||||
},
|
||||
|_me, result, ctx| {
|
||||
recovery,
|
||||
move |me, result, ctx| {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("[bedrock] AWS login completed successfully; reloading credentials before resuming");
|
||||
|
||||
// Refresh through ApiKeyManager and wait for its completion. A fixed
|
||||
// delay can resume the request while the SDK still has stale SSO data.
|
||||
let refresh = ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
|
||||
});
|
||||
let _ = ctx.spawn(refresh, |me, result, ctx| {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("[bedrock] AWS credentials refreshed; resuming conversation");
|
||||
let conversation_id = if FeatureFlag::AgentView.is_enabled() {
|
||||
me.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
} else {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.last_conversation_id(me.id())
|
||||
};
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
me.handle_resume_conversation(&conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"[bedrock] AWS login succeeded but credential reload failed; not resuming: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some(conversation_id) = conversation_id else {
|
||||
return;
|
||||
};
|
||||
if BedrockAuthConnection::current(ctx) != connection
|
||||
|| Self::thread_start_exchange_id(&conversation_id, ctx) != exchange_id
|
||||
|| BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation_status(&conversation_id) != Some(&ConversationStatus::Error)
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("[bedrock] AWS credentials refreshed; resuming originating conversation");
|
||||
me.handle_resume_conversation(&conversation_id, ctx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[bedrock] AWS login failed: {e}");
|
||||
Err(error) => {
|
||||
log::warn!("[bedrock] AWS authentication recovery stopped: {error}");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -20765,36 +20733,12 @@ impl TerminalView {
|
||||
ctx.emit(Event::OpenThemeChooser);
|
||||
}
|
||||
AIBlockEvent::RunAwsLoginCommand => {
|
||||
self.run_aws_login_command(ctx);
|
||||
let conversation_id = block.as_ref(ctx).conversation_id();
|
||||
self.recover_aws_authentication(Some(conversation_id), true, ctx);
|
||||
}
|
||||
AIBlockEvent::RefreshAwsCredentials => {
|
||||
let refresh = ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
|
||||
});
|
||||
let _ = ctx.spawn(refresh, |me, result, ctx| {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("[bedrock] AWS credentials reloaded; resuming conversation");
|
||||
let conversation_id = if FeatureFlag::AgentView.is_enabled() {
|
||||
me.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
} else {
|
||||
BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(me.id())
|
||||
};
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
me.handle_resume_conversation(&conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"[bedrock] AWS credential reload failed; starting configured login command: {error}"
|
||||
);
|
||||
me.run_aws_login_command(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
let conversation_id = block.as_ref(ctx).conversation_id();
|
||||
self.recover_aws_authentication(Some(conversation_id), false, ctx);
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::workflows::workflow::Workflow;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref LAUNCH_CONFIG_COMMENT: String = format!(
|
||||
"# Warp Launch Configuration
|
||||
"# Galaxy Launch Configuration
|
||||
#
|
||||
#
|
||||
# Use this to start a certain configuration of windows, tabs, and panes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user