Add ACP agent backend and terminal controls
This commit is contained in:
@@ -40,6 +40,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -91,6 +92,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -318,6 +318,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -372,6 +373,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -67,6 +67,7 @@ use crate::features::FeatureFlag;
|
||||
use crate::global_resource_handles::GlobalResourceHandlesProvider;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::editor::model::FileLinkResolutionContext;
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::server_api::AIApiError;
|
||||
@@ -269,6 +270,15 @@ enum RunningCommandDetection {
|
||||
Skip,
|
||||
}
|
||||
|
||||
fn acp_backend_model_id(backend: &AgentBackend) -> Option<LLMId> {
|
||||
match backend {
|
||||
AgentBackend::Provider => None,
|
||||
AgentBackend::Acp(acp) => {
|
||||
Some(format!("acp:{}", acp.agent_id.trim().to_ascii_lowercase()).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestInput {
|
||||
fn for_task(
|
||||
inputs: Vec<AIAgentInput>,
|
||||
@@ -475,6 +485,58 @@ struct InputQuery {
|
||||
queued_query_id: Option<QueuedQueryId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LiveSteeringEligibility {
|
||||
is_user_initiated: bool,
|
||||
has_shared_session_participant: bool,
|
||||
is_queued_prompt: bool,
|
||||
has_queued_query_id: bool,
|
||||
has_additional_attachments: bool,
|
||||
is_existing_task: bool,
|
||||
is_active_conversation: bool,
|
||||
has_plain_user_input: bool,
|
||||
has_pending_context: bool,
|
||||
has_action_context: bool,
|
||||
has_pending_passive_results: bool,
|
||||
}
|
||||
|
||||
impl LiveSteeringEligibility {
|
||||
fn can_attempt(self) -> bool {
|
||||
self.is_user_initiated
|
||||
&& !self.has_shared_session_participant
|
||||
&& !self.is_queued_prompt
|
||||
&& !self.has_queued_query_id
|
||||
&& !self.has_additional_attachments
|
||||
&& self.is_existing_task
|
||||
&& self.is_active_conversation
|
||||
&& self.has_plain_user_input
|
||||
&& !self.has_pending_context
|
||||
&& !self.has_action_context
|
||||
&& !self.has_pending_passive_results
|
||||
}
|
||||
}
|
||||
|
||||
fn is_plain_live_steering_input(
|
||||
input_query: &InputQueryType,
|
||||
is_same_conversation_running_command_monitor: bool,
|
||||
) -> bool {
|
||||
let InputQueryType::UserSubmittedQueryFromInput {
|
||||
query,
|
||||
static_query_type,
|
||||
running_command,
|
||||
} = input_query
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (_, user_query_mode) = extract_user_query_mode(query.clone());
|
||||
!query.trim().is_empty()
|
||||
&& !query.trim_start().starts_with('/')
|
||||
&& SlashCommandRequest::from_query(query).is_none()
|
||||
&& static_query_type.is_none()
|
||||
&& (running_command.is_none() || is_same_conversation_running_command_monitor)
|
||||
&& matches!(user_query_mode, UserQueryMode::Normal)
|
||||
}
|
||||
|
||||
impl InputQuery {
|
||||
fn query(&self) -> String {
|
||||
match &self.input_query {
|
||||
@@ -725,6 +787,7 @@ impl BlocklistAIController {
|
||||
is_queued_prompt: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let has_shared_session_participant = shared_session_participant_id.is_some();
|
||||
// Store the participant who initiated this query before sending
|
||||
// so that send_query can use it when creating the exchange.
|
||||
if let Some(participant_id) = shared_session_participant_id {
|
||||
@@ -732,6 +795,7 @@ impl BlocklistAIController {
|
||||
}
|
||||
|
||||
let query = input_query.query().to_owned();
|
||||
let is_existing_task = matches!(&input_query.which_task, WhichTask::Task { .. });
|
||||
let (conversation_id, task_id) = match input_query.which_task {
|
||||
WhichTask::NewConversation => {
|
||||
let conversation = self.start_new_conversation_for_request(ctx);
|
||||
@@ -743,6 +807,79 @@ impl BlocklistAIController {
|
||||
} => (conversation_id, task_id),
|
||||
};
|
||||
|
||||
let active_conversation_id =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_surface_id);
|
||||
let is_same_conversation_running_command_monitor = match &input_query.input_query {
|
||||
InputQueryType::UserSubmittedQueryFromInput {
|
||||
running_command: Some(running_command),
|
||||
..
|
||||
} => {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
running_command,
|
||||
)
|
||||
}
|
||||
InputQueryType::UserSubmittedQueryFromInput {
|
||||
running_command: None,
|
||||
..
|
||||
}
|
||||
| InputQueryType::AIInputType { .. } => false,
|
||||
};
|
||||
let has_simple_user_input = is_plain_live_steering_input(
|
||||
&input_query.input_query,
|
||||
is_same_conversation_running_command_monitor,
|
||||
);
|
||||
let has_pending_context = {
|
||||
let context_model = self.context_model.as_ref(ctx);
|
||||
!context_model.pending_context_block_ids().is_empty()
|
||||
|| context_model.pending_context_selected_text().is_some()
|
||||
|| !context_model.pending_attachments().is_empty()
|
||||
|| context_model.pending_document_id().is_some()
|
||||
};
|
||||
let has_action_context = {
|
||||
let action_model = self.action_model.as_ref(ctx);
|
||||
action_model.has_unfinished_actions_for_conversation(conversation_id)
|
||||
|| action_model
|
||||
.get_finished_action_results(conversation_id)
|
||||
.is_some_and(|results| !results.is_empty())
|
||||
};
|
||||
let can_attempt_live_steering = LiveSteeringEligibility {
|
||||
is_user_initiated: matches!(entrypoint_type, EntrypointType::UserInitiated),
|
||||
has_shared_session_participant,
|
||||
is_queued_prompt,
|
||||
has_queued_query_id: input_query.queued_query_id.is_some(),
|
||||
has_additional_attachments: !input_query.additional_attachments.is_empty(),
|
||||
is_existing_task,
|
||||
is_active_conversation: active_conversation_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| *id == conversation_id),
|
||||
has_plain_user_input: has_simple_user_input,
|
||||
has_pending_context,
|
||||
has_action_context,
|
||||
has_pending_passive_results: self
|
||||
.pending_passive_suggestion_results
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|results| !results.is_empty()),
|
||||
}
|
||||
.can_attempt();
|
||||
if can_attempt_live_steering {
|
||||
if let Some((stream_id, model_id)) = self
|
||||
.in_flight_response_streams
|
||||
.try_steer_acp_stream_for_conversation(conversation_id, query.clone(), ctx)
|
||||
{
|
||||
ctx.emit(BlocklistAIControllerEvent::SentRequest {
|
||||
contains_user_query: true,
|
||||
is_queued_prompt: false,
|
||||
model_id,
|
||||
stream_id,
|
||||
});
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any queued passive suggestion results for this conversation
|
||||
// *before* cancelling progress, since cancel_conversation_progress
|
||||
// clears the pending map.
|
||||
@@ -751,9 +888,6 @@ impl BlocklistAIController {
|
||||
.remove(&conversation_id)
|
||||
.unwrap_or_default();
|
||||
|
||||
let ai_history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let active_conversation_id =
|
||||
ai_history_model.active_conversation_id(self.terminal_surface_id);
|
||||
let cancellation_reason = CancellationReason::FollowUpSubmitted {
|
||||
is_for_same_conversation: active_conversation_id
|
||||
.is_some_and(|id| id == conversation_id),
|
||||
@@ -2789,7 +2923,7 @@ impl BlocklistAIController {
|
||||
/// flow that handles existing conversations properly.
|
||||
fn send_request_input(
|
||||
&mut self,
|
||||
request_input: RequestInput,
|
||||
mut request_input: RequestInput,
|
||||
query_metadata: Option<RequestMetadata>,
|
||||
can_attempt_resume_on_error: bool,
|
||||
is_queued_prompt: bool,
|
||||
@@ -2806,6 +2940,7 @@ impl BlocklistAIController {
|
||||
bedrock_history,
|
||||
bedrock_tool_result_archive,
|
||||
bedrock_progressive_summary,
|
||||
agent_backend,
|
||||
) = {
|
||||
let Some(conversation) = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -2831,9 +2966,20 @@ impl BlocklistAIController {
|
||||
conversation.bedrock_message_history().to_vec(),
|
||||
conversation.tool_result_archive().to_vec(),
|
||||
conversation.progressive_summary().map(str::to_string),
|
||||
conversation.agent_backend().clone(),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(acp_model_id) = acp_backend_model_id(&agent_backend) {
|
||||
// ACP agents own model selection. Keep every native exchange,
|
||||
// identifier, and SentRequest event from attributing this turn to
|
||||
// whichever LiteLLM/Bedrock model happens to be selected in Galaxy.
|
||||
request_input.model_id = acp_model_id.clone();
|
||||
request_input.coding_model_id = acp_model_id.clone();
|
||||
request_input.cli_agent_model_id = acp_model_id.clone();
|
||||
request_input.computer_use_model_id = acp_model_id;
|
||||
}
|
||||
|
||||
// Cancel any pending auto-resume for this conversation, since the user is sending a new
|
||||
// request.
|
||||
if let Some(handle) = self
|
||||
@@ -2954,6 +3100,7 @@ impl BlocklistAIController {
|
||||
ResponseStream::new(
|
||||
request_params.clone(),
|
||||
ai_identifiers,
|
||||
agent_backend.clone(),
|
||||
can_attempt_resume_on_error,
|
||||
ctx,
|
||||
)
|
||||
@@ -3325,7 +3472,9 @@ impl BlocklistAIController {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// If this controller is part of a shared session, forward the entire response event to viewers first.
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled() {
|
||||
if FeatureFlag::AgentSharedSessions.is_enabled()
|
||||
&& !response_stream.as_ref(ctx).is_acp()
|
||||
{
|
||||
let mut model = self.terminal_model.lock();
|
||||
if model.shared_session_status().is_sharer() {
|
||||
// Get the participant who initiated this response, falling back to the sharer if needed.
|
||||
@@ -3360,6 +3509,18 @@ impl BlocklistAIController {
|
||||
match event {
|
||||
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
||||
history_model.update(ctx, |history_model, ctx| {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Some(session_id) = response_stream
|
||||
.as_ref(ctx)
|
||||
.acp_session_metadata()
|
||||
.and_then(|metadata| metadata.session_id)
|
||||
{
|
||||
history_model.set_acp_session_id(
|
||||
conversation_id,
|
||||
session_id,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
history_model.initialize_output_for_response_stream(
|
||||
&stream_id,
|
||||
conversation_id,
|
||||
@@ -3393,15 +3554,19 @@ impl BlocklistAIController {
|
||||
// After the stream finishes, persist the full message
|
||||
// history (input + assistant response) from the Arc back
|
||||
// into the conversation for the next request cycle.
|
||||
let messages_sent_arc =
|
||||
response_stream.as_ref(ctx).bedrock_messages_sent().clone();
|
||||
let new_history = messages_sent_arc.lock().ok().and_then(|sent| {
|
||||
if sent.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sent.clone())
|
||||
}
|
||||
});
|
||||
let new_history = (!response_stream.as_ref(ctx).is_acp())
|
||||
.then(|| {
|
||||
response_stream.as_ref(ctx).bedrock_messages_sent().clone()
|
||||
})
|
||||
.and_then(|messages_sent| {
|
||||
messages_sent.lock().ok().and_then(|sent| {
|
||||
if sent.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sent.clone())
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(mut new_history) = new_history {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
history_model.update(ctx, |history_model, _| {
|
||||
@@ -3496,8 +3661,9 @@ impl BlocklistAIController {
|
||||
const MAX_ERROR_RETRIES: usize = 2;
|
||||
let retry_count =
|
||||
self.error_retry_counts.entry(conversation_id).or_insert(0);
|
||||
let should_corrective_retry =
|
||||
is_corrective_retry_candidate && *retry_count < MAX_ERROR_RETRIES;
|
||||
let should_corrective_retry = !response_stream.as_ref(ctx).is_acp()
|
||||
&& is_corrective_retry_candidate
|
||||
&& *retry_count < MAX_ERROR_RETRIES;
|
||||
|
||||
if should_corrective_retry {
|
||||
*retry_count += 1;
|
||||
@@ -4606,6 +4772,19 @@ fn get_running_command_for_conversation(
|
||||
Some(running_command_snapshot(terminal_model))
|
||||
}
|
||||
|
||||
fn running_command_belongs_to_monitor(
|
||||
terminal_model: &TerminalModel,
|
||||
conversation_id: AIConversationId,
|
||||
running_command: &RunningCommand,
|
||||
) -> bool {
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
active_block.id() == &running_command.block_id
|
||||
&& active_block.is_agent_monitoring()
|
||||
&& active_block
|
||||
.agent_interaction_metadata()
|
||||
.is_some_and(|metadata| metadata.conversation_id() == &conversation_id)
|
||||
}
|
||||
|
||||
fn running_command_snapshot(terminal_model: &TerminalModel) -> RunningCommand {
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
let is_alt_screen_active = terminal_model.is_alt_screen_active();
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::response_stream::{ResponseStream, ResponseStreamId};
|
||||
use super::BlocklistAIController;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::CancellationReason;
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
pub(super) struct PendingResponseStreams {
|
||||
@@ -51,6 +52,29 @@ impl PendingResponseStreams {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Attempts to inject a plain-text follow-up into the active ACP turn.
|
||||
///
|
||||
/// Returning `None` leaves the caller free to use the normal
|
||||
/// cancel-and-queue path without dropping the user's message.
|
||||
pub fn try_steer_acp_stream_for_conversation(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
display_text: String,
|
||||
app: &AppContext,
|
||||
) -> Option<(ResponseStreamId, LLMId)> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let conversation = history_model.conversation(&conversation_id)?;
|
||||
let (stream_id, stream) = self
|
||||
.streams
|
||||
.iter()
|
||||
.find(|(stream_id, _)| conversation.is_processing_response_stream(stream_id))?;
|
||||
let model_id = stream.as_ref(app).llm_id().clone();
|
||||
stream
|
||||
.as_ref(app)
|
||||
.try_steer_acp(display_text)
|
||||
.then(|| (stream_id.clone(), model_id))
|
||||
}
|
||||
|
||||
pub fn register_new_stream(
|
||||
&mut self,
|
||||
stream_id: ResponseStreamId,
|
||||
|
||||
@@ -3,25 +3,46 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use ::local_control::remote_command::is_potential_remote_ssh_command;
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use futures::channel::oneshot;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::acp::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, resolve_acp_launch,
|
||||
resolve_acp_permissions, validate_acp_dispatch, validate_acp_launch_identity, AcpRuntimeModel,
|
||||
AcpSessionHandleSlot, AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
|
||||
};
|
||||
use crate::ai::agent::api::{self, generate_multi_agent_output, ConvertToAPITypeError};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent::AIAgentInput;
|
||||
use crate::ai::agent::{AIIdentifiers, CancellationReason};
|
||||
use crate::ai::bedrock::client::BedrockClientConfig;
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::provider::ProviderConfig;
|
||||
use crate::network::NetworkStatus;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::pane_group::PaneGroup;
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::server::server_api::AIApiError;
|
||||
use crate::{report_error, send_telemetry_from_ctx, AISettings};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::settings::LocalControlSettings;
|
||||
use crate::{report_error, send_telemetry_from_ctx, AISettings, BlocklistAIHistoryModel};
|
||||
|
||||
/// Maximum number of times a single MAA request is re-sent before the failure is
|
||||
/// surfaced.
|
||||
@@ -82,6 +103,14 @@ impl ResponseStreamId {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
struct AcpRequestControl {
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
session_handle: AcpSessionHandleSlot,
|
||||
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
|
||||
}
|
||||
|
||||
/// Model wrapping an agent API response stream.
|
||||
///
|
||||
/// Emits events when the output corresponding to the stream is updated, typically after receiving
|
||||
@@ -91,6 +120,13 @@ impl ResponseStreamId {
|
||||
/// received yet, ensuring we don't retry after the AI has started executing actions.
|
||||
pub struct ResponseStream {
|
||||
id: ResponseStreamId,
|
||||
agent_backend: AgentBackend,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: AcpSessionHandleSlot,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::Sender<AcpSteeringRequest>,
|
||||
params: api::RequestParams,
|
||||
retry_count: usize,
|
||||
/// One-time fallback from the profile's thinking model to its coding model.
|
||||
@@ -157,6 +193,13 @@ impl ResponseStream {
|
||||
let (cancellation_tx, _rx) = oneshot::channel();
|
||||
Self {
|
||||
id,
|
||||
agent_backend: AgentBackend::Provider,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle: Arc::new(Mutex::new(None)),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx: async_channel::unbounded().0,
|
||||
params: api::RequestParams::new_for_test(),
|
||||
retry_count: 0,
|
||||
coding_model_fallback_attempted: false,
|
||||
@@ -223,9 +266,155 @@ impl ResponseStream {
|
||||
ProviderConfig::None
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn resolve_acp_manager(
|
||||
backend: &crate::persistence::model::AcpConversationData,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<galaxy_acp::AcpSessionManager, String> {
|
||||
use galaxy_acp::AcpManagerConfig;
|
||||
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
||||
let configured_agent_id = if configured_agent_id.is_empty() {
|
||||
"codex"
|
||||
} else {
|
||||
configured_agent_id
|
||||
};
|
||||
let launch = resolve_acp_launch(
|
||||
configured_agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
settings.acp_agent_args.value(),
|
||||
)?;
|
||||
validate_acp_launch_identity(
|
||||
backend,
|
||||
configured_agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
&launch,
|
||||
)?;
|
||||
let config = AcpManagerConfig::new(launch);
|
||||
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn spawn_acp_request(
|
||||
backend: crate::persistence::model::AcpConversationData,
|
||||
params: api::RequestParams,
|
||||
conversation_key: String,
|
||||
request_id: Uuid,
|
||||
control: AcpRequestControl,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
} = control;
|
||||
let profile = BlocklistAIPermissions::as_ref(ctx)
|
||||
.active_permissions_profile(ctx, params.terminal_view_id);
|
||||
let permissions = resolve_acp_permissions(&profile);
|
||||
let targets_remote_terminal = params.session_context.is_remote()
|
||||
|| params.input.iter().any(|input| {
|
||||
let AIAgentInput::UserQuery {
|
||||
running_command: Some(command),
|
||||
..
|
||||
} = input
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
is_interactive_remote_command(&command.command)
|
||||
});
|
||||
let manager = validate_acp_dispatch(
|
||||
FeatureFlag::AgentClientProtocol.is_enabled(),
|
||||
*AISettings::as_ref(ctx).acp_enabled.value(),
|
||||
targets_remote_terminal,
|
||||
)
|
||||
.and_then(|()| Self::resolve_acp_manager(&backend, ctx));
|
||||
let galaxy_mcp_server = if cfg!(unix)
|
||||
&& manager.is_ok()
|
||||
&& permissions.expose_galaxy_tools
|
||||
&& FeatureFlag::GalaxyControlCli.is_enabled()
|
||||
&& LocalControlSettings::as_ref(ctx).is_enabled()
|
||||
{
|
||||
match params
|
||||
.terminal_view_id
|
||||
.and_then(|terminal_view_id| terminal_pane_id(terminal_view_id, ctx))
|
||||
{
|
||||
Some(target) => match galaxy_mcp_server(
|
||||
&target,
|
||||
permissions.allow_terminal_execute,
|
||||
permissions.allow_terminal_interrupt,
|
||||
) {
|
||||
Ok(server) => Some(server),
|
||||
Err(error) => {
|
||||
log::warn!("Galaxy MCP tools are unavailable for ACP: {error}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::warn!(
|
||||
"Galaxy MCP tools are unavailable for ACP because the originating terminal pane could not be resolved"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let galaxy_terminal_interrupt_available =
|
||||
galaxy_mcp_server.is_some() && permissions.allow_terminal_interrupt;
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let stream = match manager {
|
||||
Ok(manager) => {
|
||||
acp_output_stream(
|
||||
manager,
|
||||
params,
|
||||
conversation_key,
|
||||
backend,
|
||||
galaxy_mcp_server,
|
||||
galaxy_terminal_interrupt_available,
|
||||
permissions.policy,
|
||||
permissions.auto_approve_protocol_requests,
|
||||
session_metadata,
|
||||
session_handle,
|
||||
steering_rx,
|
||||
cancellation_rx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(message) => acp_startup_error_stream(¶ms, &backend, &message),
|
||||
};
|
||||
Ok::<_, ConvertToAPITypeError>(stream)
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_provider_request(
|
||||
params: api::RequestParams,
|
||||
provider_config: ProviderConfig,
|
||||
request_id: Uuid,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let _ =
|
||||
ctx.spawn(
|
||||
async move {
|
||||
generate_multi_agent_output(provider_config, params, cancellation_rx).await
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
params: api::RequestParams,
|
||||
ai_identifiers: AIIdentifiers,
|
||||
agent_backend: AgentBackend,
|
||||
can_attempt_resume_on_error: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
@@ -233,18 +422,66 @@ impl ResponseStream {
|
||||
let start_time = Local::now();
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||
let params_clone = params.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
generate_multi_agent_output(provider_config, params_clone, cancellation_rx).await
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let acp_session_handle = Arc::new(Mutex::new(None));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let (acp_steering_tx, acp_steering_rx) = async_channel::unbounded();
|
||||
match &agent_backend {
|
||||
AgentBackend::Provider => {
|
||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||
Self::spawn_provider_request(
|
||||
params.clone(),
|
||||
provider_config,
|
||||
request_id,
|
||||
cancellation_rx,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
AgentBackend::Acp(backend) => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::spawn_acp_request(
|
||||
backend.clone(),
|
||||
params.clone(),
|
||||
ai_identifiers
|
||||
.client_conversation_id
|
||||
.map(|id| format!("{id:?}"))
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
request_id,
|
||||
AcpRequestControl {
|
||||
cancellation_rx,
|
||||
session_metadata: acp_session_metadata.clone(),
|
||||
session_handle: acp_session_handle.clone(),
|
||||
steering_rx: acp_steering_rx,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let error = Arc::new(AIApiError::Stream {
|
||||
stream_type: "acp",
|
||||
source: anyhow!("ACP is unavailable in the web client"),
|
||||
});
|
||||
let stream = Box::pin(futures::stream::once(async move { Err(error) }));
|
||||
let _ = ctx.spawn(
|
||||
async move { Ok::<_, ConvertToAPITypeError>(stream) },
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
id: ResponseStreamId(Uuid::new_v4().to_string()),
|
||||
agent_backend,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_metadata,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_session_handle,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
acp_steering_tx,
|
||||
params: params.clone(),
|
||||
start_time,
|
||||
time_to_latest_event: TimeDelta::seconds(0),
|
||||
@@ -267,6 +504,50 @@ impl ResponseStream {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn is_acp(&self) -> bool {
|
||||
matches!(self.agent_backend, AgentBackend::Acp(_))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn acp_session_metadata(&self) -> Option<AcpSessionMetadata> {
|
||||
self.is_acp()
|
||||
.then(|| {
|
||||
self.acp_session_metadata
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|state| state.clone())
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) fn try_steer_acp(&self, display_text: String) -> bool {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
if !self.is_acp()
|
||||
|| self.current_request_id.is_none()
|
||||
|| !self
|
||||
.acp_session_metadata()
|
||||
.is_some_and(|metadata| metadata.can_steer)
|
||||
|| !self
|
||||
.acp_session_handle
|
||||
.lock()
|
||||
.is_ok_and(|session| session.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut model_text = display_text.clone();
|
||||
self.params.redact_text_for_model(&mut model_text);
|
||||
self.acp_steering_tx
|
||||
.try_send(AcpSteeringRequest::text(display_text, model_text))
|
||||
.is_ok()
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let _ = display_text;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bedrock_messages_sent(
|
||||
&self,
|
||||
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>
|
||||
@@ -279,6 +560,10 @@ impl ResponseStream {
|
||||
self.params.model.as_str()
|
||||
}
|
||||
|
||||
pub(super) fn llm_id(&self) -> &LLMId {
|
||||
&self.params.model
|
||||
}
|
||||
|
||||
/// Returns true if we should attempt to resume the conversation after the stream finishes.
|
||||
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
|
||||
self.should_resume_conversation_after_stream_finished
|
||||
@@ -334,7 +619,8 @@ impl ResponseStream {
|
||||
&self,
|
||||
error: &Arc<crate::server::server_api::AIApiError>,
|
||||
) -> bool {
|
||||
if self.coding_model_fallback_attempted || self.has_received_client_actions {
|
||||
if self.is_acp() || self.coding_model_fallback_attempted || self.has_received_client_actions
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let coding_model = self.params.coding_model.as_str();
|
||||
@@ -507,7 +793,7 @@ impl ResponseStream {
|
||||
let is_online = NetworkStatus::as_ref(ctx).is_online();
|
||||
match recovery_action(
|
||||
self.has_received_client_actions,
|
||||
e.is_recoverable(),
|
||||
e.is_recoverable() && !self.is_acp(),
|
||||
self.retry_count < MAX_RETRIES,
|
||||
self.can_attempt_resume_on_error,
|
||||
is_online,
|
||||
@@ -580,7 +866,7 @@ impl ResponseStream {
|
||||
let is_online = NetworkStatus::as_ref(ctx).is_online();
|
||||
match recovery_action(
|
||||
self.has_received_client_actions,
|
||||
unexpected_eof.is_recoverable(),
|
||||
unexpected_eof.is_recoverable() && !self.is_acp(),
|
||||
self.retry_count < MAX_RETRIES,
|
||||
self.can_attempt_resume_on_error,
|
||||
is_online,
|
||||
@@ -694,6 +980,38 @@ impl ResponseStream {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn is_interactive_remote_command(command: &str) -> bool {
|
||||
is_potential_remote_ssh_command(command)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn terminal_pane_id(
|
||||
terminal_view_id: galaxyui::EntityId,
|
||||
ctx: &ModelContext<ResponseStream>,
|
||||
) -> Option<GalaxyMcpTarget> {
|
||||
let window_ids = ctx.window_ids().collect::<Vec<_>>();
|
||||
for window_id in window_ids {
|
||||
let Some(pane_groups) = ctx.views_of_type::<PaneGroup>(window_id) else {
|
||||
continue;
|
||||
};
|
||||
for pane_group in pane_groups {
|
||||
let tab_id = pane_group.id().to_string();
|
||||
if let Some(pane_id) = pane_group
|
||||
.as_ref(ctx)
|
||||
.find_pane_id_for_terminal_view(terminal_view_id, ctx)
|
||||
{
|
||||
return Some(GalaxyMcpTarget {
|
||||
window_id: window_id.to_string(),
|
||||
tab_id,
|
||||
pane_id: pane_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Consumable<T> {
|
||||
value: Rc<RefCell<Option<T>>>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{recovery_action, RecoveryAction};
|
||||
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
|
||||
|
||||
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
|
||||
// can_attempt_resume_on_error, is_online.
|
||||
@@ -82,3 +82,33 @@ fn non_recoverable_post_action_failure_is_terminal() {
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
|
||||
for command in [
|
||||
"ssh user@example.com",
|
||||
"command ssh -p 2222 user@example.com",
|
||||
" /usr/bin/ssh user@example.com",
|
||||
"GALAXY_TEST=1 ssh user@example.com",
|
||||
"env GALAXY_TEST=1 ssh user@example.com",
|
||||
"/usr/bin/env -- GALAXY_TEST=1 /usr/bin/ssh user@example.com",
|
||||
"sudo -u root ssh user@example.com",
|
||||
"cd /tmp && ssh user@example.com",
|
||||
"bash -lc 'ssh user@example.com'",
|
||||
"gcloud compute ssh --zone us-central1-a instance",
|
||||
"ssh user@example.com uname -a",
|
||||
"ssh -T git@example.com",
|
||||
] {
|
||||
assert!(is_interactive_remote_command(command), "{command}");
|
||||
}
|
||||
for command in [
|
||||
"cargo test",
|
||||
"echo /usr/bin/ssh user@example.com",
|
||||
"GALAXY_TEST=/usr/bin/ssh cargo test",
|
||||
"env GALAXY_TEST=1 cargo test",
|
||||
"/usr/bin/ssh-add user@example.com",
|
||||
"bash -lc 'echo ssh user@example.com'",
|
||||
] {
|
||||
assert!(!is_interactive_remote_command(command), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAttachment, AIAgentContext, AIAgentInput, CancellationReason, ImageContext,
|
||||
PassiveSuggestionTrigger, UserQueryMode,
|
||||
PassiveSuggestionTrigger, RunningCommand, UserQueryMode,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::{
|
||||
@@ -18,6 +18,8 @@ use crate::ai::blocklist::{
|
||||
ResponseStream, ResponseStreamId,
|
||||
};
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
|
||||
|
||||
fn new_ambient_agent_task_id() -> AmbientAgentTaskId {
|
||||
@@ -41,6 +43,116 @@ fn file_attachment(file_name: &str) -> PendingAttachment {
|
||||
})
|
||||
}
|
||||
|
||||
fn live_steering_eligibility() -> super::LiveSteeringEligibility {
|
||||
super::LiveSteeringEligibility {
|
||||
is_user_initiated: true,
|
||||
has_shared_session_participant: false,
|
||||
is_queued_prompt: false,
|
||||
has_queued_query_id: false,
|
||||
has_additional_attachments: false,
|
||||
is_existing_task: true,
|
||||
is_active_conversation: true,
|
||||
has_plain_user_input: true,
|
||||
has_pending_context: false,
|
||||
has_action_context: false,
|
||||
has_pending_passive_results: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_backend_model_identity_does_not_claim_a_provider_model() {
|
||||
assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None);
|
||||
assert_eq!(
|
||||
super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: " Codex ".to_owned(),
|
||||
launch_fingerprint: "launch-123".to_owned(),
|
||||
session_id: None,
|
||||
})),
|
||||
Some(LLMId::from("acp:codex"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_steering_accepts_plain_input_for_the_existing_command_monitor() {
|
||||
let input = super::InputQueryType::UserSubmittedQueryFromInput {
|
||||
query: "Stop the command now.".to_owned(),
|
||||
static_query_type: None,
|
||||
running_command: Some(RunningCommand {
|
||||
command: "script/soak-test".to_owned(),
|
||||
block_id: BlockId::new(),
|
||||
grid_contents: "elapsed: 75s".to_owned(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(!super::is_plain_live_steering_input(&input, false));
|
||||
assert!(super::is_plain_live_steering_input(&input, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_command_monitor_identity_requires_the_same_conversation_and_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
terminal.update(&mut app, |terminal, _ctx| {
|
||||
let mut terminal_model = terminal.model.lock();
|
||||
terminal_model.simulate_long_running_block("sleep 100", "running");
|
||||
let task_id = TaskId::new("monitor-task".to_owned());
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
active_block.set_is_agent_tagged_in(true);
|
||||
active_block
|
||||
.set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id)
|
||||
.expect("tagged command should transition to agent monitoring");
|
||||
|
||||
let running_command = super::running_command_snapshot(&terminal_model);
|
||||
assert!(super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
&running_command,
|
||||
));
|
||||
assert!(!super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
AIConversationId::new(),
|
||||
&running_command,
|
||||
));
|
||||
|
||||
let mut other_block = running_command;
|
||||
other_block.block_id = BlockId::new();
|
||||
assert!(!super::running_command_belongs_to_monitor(
|
||||
&terminal_model,
|
||||
conversation_id,
|
||||
&other_block,
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_steering_retains_attachment_context_and_action_guards() {
|
||||
let eligible = live_steering_eligibility();
|
||||
assert!(eligible.can_attempt());
|
||||
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_additional_attachments: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_pending_context: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
assert!(!super::LiveSteeringEligibility {
|
||||
has_action_context: true,
|
||||
..eligible
|
||||
}
|
||||
.can_attempt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_suggestions_request_params_omit_ambient_agent_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -11,6 +11,7 @@ use diesel::SqliteConnection;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use itertools::Itertools as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_multi_agent_api::client_action::{Action, StartNewConversation};
|
||||
@@ -22,6 +23,8 @@ use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
||||
use super::controller::response_stream::ResponseStreamId;
|
||||
use super::persistence::{PersistedAIInput, PersistedAIInputType};
|
||||
use super::RequestInput;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::acp::acp_launch_fingerprint;
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::{
|
||||
AIConversation, AIConversationId, ConversationStatus, ServerAIConversationMetadata,
|
||||
@@ -37,11 +40,14 @@ use crate::ai::agent::{
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
use crate::persistence::model::{AgentConversation, AgentConversationData};
|
||||
use crate::persistence::model::{
|
||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::view::blocklist_filter;
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -279,6 +285,24 @@ pub struct BlocklistAIHistoryModel {
|
||||
}
|
||||
|
||||
impl BlocklistAIHistoryModel {
|
||||
/// Stores an agent-owned ACP session ID without reusing the cloud
|
||||
/// conversation-token field.
|
||||
pub(crate) fn set_acp_session_id(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
session_id: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let updated = self
|
||||
.conversations_by_id
|
||||
.get_mut(&conversation_id)
|
||||
.is_some_and(|conversation| conversation.set_acp_session_id(session_id));
|
||||
if updated {
|
||||
self.persist_conversation_state(conversation_id, ctx);
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
persisted_queries: Vec<PersistedAIInput>,
|
||||
multi_agent_conversations: &[AgentConversation],
|
||||
@@ -1171,8 +1195,43 @@ impl BlocklistAIHistoryModel {
|
||||
is_cli_agent_transcript: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> AIConversationId {
|
||||
let mut new_conversation =
|
||||
AIConversation::new(is_viewing_shared_session, is_cli_agent_transcript);
|
||||
let agent_backend = if !is_viewing_shared_session
|
||||
&& !is_cli_agent_transcript
|
||||
&& cfg!(unix)
|
||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
{
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
if *settings.acp_enabled.value() {
|
||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
||||
let agent_id = if configured_agent_id.is_empty() {
|
||||
"codex"
|
||||
} else {
|
||||
configured_agent_id
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let launch_fingerprint = acp_launch_fingerprint(
|
||||
agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
settings.acp_agent_args.value(),
|
||||
);
|
||||
#[cfg(target_family = "wasm")]
|
||||
let launch_fingerprint = String::new();
|
||||
AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: agent_id.to_string(),
|
||||
launch_fingerprint,
|
||||
session_id: None,
|
||||
})
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
}
|
||||
} else {
|
||||
AgentBackend::Provider
|
||||
};
|
||||
let mut new_conversation = AIConversation::new_with_agent_backend(
|
||||
is_viewing_shared_session,
|
||||
is_cli_agent_transcript,
|
||||
agent_backend,
|
||||
);
|
||||
if is_autoexecute_override {
|
||||
new_conversation.toggle_autoexecute_override();
|
||||
}
|
||||
@@ -1519,6 +1578,7 @@ impl BlocklistAIHistoryModel {
|
||||
};
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
||||
reverted_action_ids,
|
||||
@@ -1682,6 +1742,7 @@ impl BlocklistAIHistoryModel {
|
||||
// Start forked conversations without usage metadata for now; this can
|
||||
// be recomputed based on the retained exchanges in a follow-up.
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: conversation.agent_backend().for_fork(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids,
|
||||
@@ -2652,7 +2713,7 @@ impl BlocklistAIHistoryModel {
|
||||
///
|
||||
/// **Placeholder authoritative** (local orchestration linkage that the cloud
|
||||
/// transcript cannot reconstruct):
|
||||
/// - `parent_conversation_id`, `is_remote_child`, `pinned`
|
||||
/// - `agent_backend`, `parent_conversation_id`, `is_remote_child`, `pinned`
|
||||
///
|
||||
/// **Placeholder-preferred, cloud fallback** (local value wins when present,
|
||||
/// cloud's value is used otherwise so we don't lose data on a stale
|
||||
@@ -2672,6 +2733,9 @@ fn merged_remote_child_placeholder_conversation_data(
|
||||
cloud_conversation: &AIConversation,
|
||||
) -> AgentConversationData {
|
||||
AgentConversationData {
|
||||
// Placeholder authoritative.
|
||||
agent_backend: placeholder.agent_backend().clone(),
|
||||
|
||||
// Cloud authoritative.
|
||||
server_conversation_token: cloud_conversation
|
||||
.server_conversation_token()
|
||||
|
||||
@@ -1123,6 +1123,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
|
||||
|
||||
let forked_conversation_id = AIConversationId::new();
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("forked-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -3,10 +3,12 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use itertools::Itertools;
|
||||
use settings::Setting;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warpui::{App, EntityId};
|
||||
use warpui::{App, EntityId, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
convert_persisted_conversation_to_ai_conversation_with_metadata, AIConversationMetadata,
|
||||
@@ -32,11 +34,13 @@ use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions};
|
||||
use crate::input_suggestions::HistoryInputSuggestion;
|
||||
use crate::persistence::model::{
|
||||
AgentConversation, AgentConversationData, AgentConversationRecord, PersistedAutoexecuteMode,
|
||||
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
|
||||
AgentConversationRecord, PersistedAutoexecuteMode,
|
||||
};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::test_util::ai_agent_tasks::{create_api_task, create_message};
|
||||
@@ -45,6 +49,44 @@ use crate::test_util::settings::{
|
||||
};
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn acp_enabled_with_empty_command_selects_codex_backend() {
|
||||
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.acp_enabled
|
||||
.set_value(true, ctx)
|
||||
.expect("ACP setting should update");
|
||||
settings
|
||||
.acp_agent_command
|
||||
.set_value(String::new(), ctx)
|
||||
.expect("empty command should select the built-in preset");
|
||||
});
|
||||
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |model, ctx| {
|
||||
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
let conversation = model
|
||||
.conversation(&conversation_id)
|
||||
.expect("conversation should exist");
|
||||
assert_eq!(
|
||||
conversation.agent_backend(),
|
||||
&AgentBackend::Acp(AcpConversationData {
|
||||
agent_id: "codex".to_string(),
|
||||
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
||||
session_id: None,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to create a PersistedAIInput for testing
|
||||
fn create_persisted_query(
|
||||
query_text: &str,
|
||||
@@ -772,6 +814,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi
|
||||
persisted_agent_conversation(
|
||||
child_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -795,6 +838,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi
|
||||
persisted_agent_conversation(
|
||||
parent_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -846,6 +890,7 @@ fn test_initialize_historical_conversations_uses_root_task_description_title() {
|
||||
id: 0,
|
||||
conversation_id: conversation_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("renamed-title-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -911,6 +956,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child
|
||||
persisted_agent_conversation(
|
||||
child_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -935,6 +981,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child
|
||||
persisted_agent_conversation(
|
||||
parent_id,
|
||||
AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3069,6 +3116,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
|
||||
|
||||
let forked_conversation_id = AIConversationId::new();
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("forked-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3261,6 +3309,7 @@ fn test_fork_then_bind_handoff_token_resolves_to_forked_conversation() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3345,6 +3394,7 @@ fn test_fork_then_bind_handoff_token_persists_to_restored_conversation() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3454,6 +3504,7 @@ fn test_fork_then_bind_handoff_token_updates_cached_metadata_and_emits_refresh_e
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3581,6 +3632,7 @@ fn test_fork_conversation_preserves_task_ids_when_requested() {
|
||||
source_id,
|
||||
vec![root_task, subtask],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("src-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3726,6 +3778,7 @@ fn test_fork_conversation_title_override_replaces_prefix() {
|
||||
source_id,
|
||||
vec![root_task],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3816,6 +3869,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_
|
||||
placeholder_id,
|
||||
vec![placeholder_root],
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -3863,6 +3917,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_
|
||||
cloud_id,
|
||||
cloud_tasks.clone(),
|
||||
Some(AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: Some("cloud-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
@@ -148,6 +148,7 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() {
|
||||
server_data: String::new(),
|
||||
};
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
Reference in New Issue
Block a user