This commit is contained in:
2026-08-05 00:56:55 -05:00
parent b0ad07f6f2
commit c321e17708
44 changed files with 2005 additions and 598 deletions
+2 -2
View File
@@ -18,6 +18,6 @@ pub(crate) use launch::{
pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot,
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget,
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpTurnControlSlot, GalaxyMcpTarget,
};
+20 -11
View File
@@ -1,4 +1,5 @@
use galaxy_acp::{ContentBlock, ImageContent, TextContent};
use base64::Engine as _;
use galaxy_agent_core::{ContentPart, MessageContent};
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
@@ -24,7 +25,7 @@ pub(super) struct GalaxyTerminalTools {
pub(super) fn prompt_content(
params: &RequestParams,
terminal_tools: GalaxyTerminalTools,
) -> Result<Vec<ContentBlock>, String> {
) -> Result<MessageContent, String> {
let visible_query = params
.input
.iter()
@@ -41,12 +42,15 @@ pub(super) fn prompt_content(
for item in context {
match item {
AIAgentContext::Image(image) => {
let mut file_name = image.file_name.clone();
params.redact_text_for_model(&mut file_name);
images.push(ContentBlock::Image(
ImageContent::new(image.data.clone(), image.mime_type.clone())
.uri(format!("attachment://{file_name}")),
));
let data = base64::engine::general_purpose::STANDARD
.decode(&image.data)
.map_err(|error| {
format!("failed to decode ACP image attachment: {error}")
})?;
images.push(ContentPart::Image {
data,
mime_type: image.mime_type.clone(),
});
}
AIAgentContext::SelectedText(text) => {
hidden_context.push(format!("Selected text:\n{text}"));
@@ -115,9 +119,14 @@ pub(super) fn prompt_content(
}
params.redact_text_for_model(&mut text);
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
prompt.extend(images);
Ok(prompt)
if images.is_empty() {
Ok(MessageContent::Text(text))
} else {
let mut parts = Vec::with_capacity(images.len() + 1);
parts.push(ContentPart::Text(text));
parts.extend(images);
Ok(MessageContent::MultiPart(parts))
}
}
fn append_hidden_input(
+56 -50
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use galaxy_acp::ContentBlock;
use galaxy_agent_core::{ContentPart, MessageContent};
use regex::Regex;
use serial_test::serial;
@@ -42,6 +42,19 @@ fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
}
}
fn prompt_text(prompt: &MessageContent) -> &str {
match prompt {
MessageContent::Text(text) => text,
MessageContent::MultiPart(parts) => match &parts[0] {
ContentPart::Text(text) => text,
_ => panic!("expected prompt text first"),
},
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => {
panic!("expected user prompt")
}
}
}
#[test]
fn keeps_images_as_native_acp_content() {
let mut params = RequestParams::new_for_test();
@@ -56,17 +69,19 @@ fn keeps_images_as_native_acp_content() {
)];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
assert_eq!(prompt.len(), 2);
let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected multipart prompt");
};
assert_eq!(parts.len(), 2);
assert!(matches!(
&prompt[0],
ContentBlock::Text(text) if text.text == "What is in this image?"
&parts[0],
ContentPart::Text(text) if text == "What is in this image?"
));
assert!(matches!(
&prompt[1],
ContentBlock::Image(image)
if image.data == "aW1hZ2U="
&& image.mime_type == "image/png"
&& image.uri.as_deref() == Some("attachment://screen.png")
&parts[1],
ContentPart::Image { data, mime_type }
if data == b"image"
&& mime_type == "image/png"
));
}
@@ -80,13 +95,11 @@ fn sends_rules_and_selected_text_without_changing_visible_query() {
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Fix this"));
assert!(text.text.contains("hidden_from_transcript"));
assert!(text.text.contains("broken()"));
assert!(text.text.contains("Run tests first."));
let text = prompt_text(&prompt);
assert!(text.starts_with("Fix this"));
assert!(text.contains("hidden_from_transcript"));
assert!(text.contains("broken()"));
assert!(text.contains("Run tests first."));
}
#[test]
@@ -98,12 +111,10 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
}];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Handle the Galaxy system request"));
assert!(text.text.contains("Repair the failing unit test."));
assert!(text.text.contains("hidden_from_transcript"));
let text = prompt_text(&prompt);
assert!(text.starts_with("Handle the Galaxy system request"));
assert!(text.contains("Repair the failing unit test."));
assert!(text.contains("hidden_from_transcript"));
}
#[test]
@@ -135,16 +146,14 @@ fn running_command_identity_and_output_are_sent_as_hidden_context() {
},
)
.expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.starts_with("Stop this after 75 seconds."));
assert!(text.text.contains(block_id.as_str()));
assert!(text.text.contains("elapsed: 41s"));
assert!(text.text.contains("galaxy_terminal_status"));
assert!(text.text.contains("running_for_ms"));
assert!(text.text.contains("galaxy_terminal_interrupt_at"));
assert!(text.text.contains("outside the model loop"));
let text = prompt_text(&prompt);
assert!(text.starts_with("Stop this after 75 seconds."));
assert!(text.contains(block_id.as_str()));
assert!(text.contains("elapsed: 41s"));
assert!(text.contains("galaxy_terminal_status"));
assert!(text.contains("running_for_ms"));
assert!(text.contains("galaxy_terminal_interrupt_at"));
assert!(text.contains("outside the model loop"));
}
#[test]
@@ -176,12 +185,10 @@ fn running_command_prompt_does_not_advertise_unavailable_mutations() {
},
)
.expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
};
assert!(text.text.contains("galaxy_terminal_status"));
assert!(text.text.contains("no Galaxy terminal mutation tool"));
assert!(!text.text.contains("galaxy_terminal_interrupt_at"));
let text = prompt_text(&prompt);
assert!(text.contains("galaxy_terminal_status"));
assert!(text.contains("no Galaxy terminal mutation tool"));
assert!(!text.contains("galaxy_terminal_interrupt_at"));
}
#[test]
@@ -224,20 +231,19 @@ fn redacts_request_text_before_creating_acp_content_blocks() {
}];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else {
panic!("expected text");
let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected multipart prompt");
};
assert!(!text.text.contains(SECRET));
assert!(text.text.contains("******************"));
assert!(text.text.contains("Selected text:"));
assert!(text.text.contains("Current output:"));
assert!(text.text.contains("Attachment notes.txt:"));
assert!(text.text.contains("Galaxy rules:"));
let text = prompt_text(&prompt);
assert!(!text.contains(SECRET));
assert!(text.contains("******************"));
assert!(text.contains("Selected text:"));
assert!(text.contains("Current output:"));
assert!(text.contains("Attachment notes.txt:"));
assert!(text.contains("Galaxy rules:"));
assert!(matches!(
&prompt[1],
ContentBlock::Image(image)
if image.data == "aW1hZ2U="
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
&parts[1],
ContentPart::Image { data, .. } if data == b"image"
));
// Prompt redaction must not mutate the local transcript copy.
+184 -162
View File
@@ -1,10 +1,11 @@
use std::collections::HashMap;
use galaxy_acp::{AcpEvent, ContentBlock, StopReason, ToolCallId, ToolCallStatus};
use galaxy_agent_core::{AgentEvent, RuntimeActivity, RuntimeActivityStatus, StopReason};
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ResponseEvent};
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use crate::ai::agent::runtime_activity;
use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
@@ -20,10 +21,11 @@ pub(super) struct AcpResponseTranslator {
model_id: String,
initialized: bool,
message_id: Option<String>,
tool_titles: HashMap<ToolCallId, String>,
activity_message_ids: HashMap<String, String>,
activities: HashMap<String, RuntimeActivity>,
has_visible_output: bool,
used_tokens: u64,
context_size: u64,
accept_next_user_content: bool,
}
impl AcpResponseTranslator {
@@ -41,169 +43,71 @@ impl AcpResponseTranslator {
model_id,
initialized: false,
message_id: None,
tool_titles: HashMap::new(),
activity_message_ids: HashMap::new(),
activities: HashMap::new(),
has_visible_output: false,
used_tokens: 0,
context_size: 0,
accept_next_user_content: false,
}
}
pub(super) fn translate(&mut self, event: AcpEvent) -> Result<Vec<ResponseEvent>, String> {
pub(super) fn translate(&mut self, event: AgentEvent) -> Result<Vec<ResponseEvent>, String> {
let mut events = Vec::new();
match event {
AcpEvent::SessionStarted { .. } => self.initialize(&mut events),
AcpEvent::AgentText { text } => {
AgentEvent::TurnStarted { .. } => self.initialize(&mut events),
AgentEvent::TextDelta { text } => {
self.initialize(&mut events);
self.add_or_append(&text, &mut events);
}
// Reasoning is deliberately not copied into the plain assistant
// transcript. ACP agents can still expose plans and tool progress.
AcpEvent::AgentThought { .. } => {}
AcpEvent::AgentContent { content, thought } => {
if !thought {
self.initialize(&mut events);
let description = match content {
ContentBlock::Text(text) => text.text,
ContentBlock::Image(_) => "[Agent returned an image.]".to_owned(),
ContentBlock::Audio(_) => "[Agent returned audio.]".to_owned(),
ContentBlock::ResourceLink(resource) => {
format!("[Agent referenced {}.]", resource.name)
}
ContentBlock::Resource(_) => {
"[Agent returned embedded resource content.]".to_owned()
}
_ => "[Agent returned unsupported content.]".to_owned(),
};
self.add_or_append(&description, &mut events);
}
// The legacy transcript has no dedicated reasoning surface on this
// path yet. The shared runtime event remains available for the UI
// convergence phase instead of being flattened into answer text.
AgentEvent::ReasoningDelta { .. } | AgentEvent::ReasoningCompleted { .. } => {}
AgentEvent::RuntimeActivityUpdated { activity } => {
self.initialize(&mut events);
self.message_id = None;
self.upsert_runtime_activity(activity, &mut events)?;
}
AcpEvent::UserContent { content } => {
// Some ACP adapters replay user-message chunks while loading a
// session or echo Galaxy's initial prompt, which also contains
// hidden context. Only content explicitly authorized by the
// live-steering path may enter the visible transcript.
if self.accept_next_user_content {
self.accept_next_user_content = false;
self.initialize(&mut events);
if let ContentBlock::Text(text) = content {
events.push(build_user_query_message(&self.task_id, &text.text));
// Assistant output after steering belongs in a new chat
// bubble, not the message that preceded the follow-up.
self.message_id = None;
}
}
}
AcpEvent::ToolCall {
id,
title,
status,
output,
AgentEvent::ContextUsageUpdated {
used_tokens,
context_size,
} => {
self.initialize(&mut events);
self.tool_titles.insert(id, title.clone());
self.add_or_append(&tool_status_line(&title, status), &mut events);
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
self.used_tokens = used_tokens;
self.context_size = context_size;
}
AcpEvent::ToolCallUpdate {
id,
title,
status,
output,
} => {
AgentEvent::UserInputAccepted { text } => {
self.initialize(&mut events);
let title = title
.or_else(|| self.tool_titles.get(&id).cloned())
.unwrap_or_else(|| "tool".to_owned());
self.tool_titles.insert(id, title.clone());
if let Some(status) = status {
self.add_or_append(&tool_status_line(&title, status), &mut events);
}
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
events.push(build_user_query_message(&self.task_id, &text));
self.message_id = None;
}
AcpEvent::Usage { used, size, .. } => {
self.used_tokens = used;
self.context_size = size;
}
AcpEvent::PermissionRequested { request } => {
AgentEvent::RuntimeNotice { message } => {
self.initialize(&mut events);
self.add_or_append(
&format!(
"\n\n> Permission requested for: {}\n",
request.tool_call.fields.title.as_deref().unwrap_or("tool")
),
&mut events,
);
self.message_id = None;
self.add_or_append(&message, &mut events);
self.message_id = None;
}
AcpEvent::PermissionResolved { decision, .. } => {
AgentEvent::TurnStopped { reason } => {
self.initialize(&mut events);
self.add_or_append(
&format!("\n\n> Permission decision: {decision:?}\n"),
&mut events,
);
}
AcpEvent::Finished { stop_reason } => {
self.initialize(&mut events);
if self.message_id.is_none() && stop_reason != StopReason::Cancelled {
if !self.has_visible_output && reason != StopReason::Cancelled {
self.add_or_append(
"> ACP agent completed without a text response.",
&mut events,
);
}
events.push(self.finished(stop_reason));
events.push(self.finished(reason));
}
AgentEvent::Tool { .. } => {
return Err(
"ACP runtime attempted to hand agent-owned tool execution to Galaxy".to_owned(),
);
}
AgentEvent::UsageUpdated { .. } => {
return Err("ACP runtime reported provider-style request usage".to_owned());
}
AcpEvent::Error { message } => return Err(message),
// ACP events are forward-compatible. Unknown events do not belong
// in the user-visible transcript until Galaxy knows their meaning.
_ => {}
}
Ok(events)
}
pub(super) fn translate_steered_user_content(
&mut self,
content: ContentBlock,
) -> Result<Vec<ResponseEvent>, String> {
self.accept_next_user_content = true;
self.translate(AcpEvent::UserContent { content })
}
pub(super) fn steering_failed(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
&format!(
"Galaxy couldn't confirm that live steering message: {error}. \
The agent may not have received it; check the current terminal and file state \
before retrying."
),
&mut events,
);
// Any output still arriving from the original turn should not be
// appended to Galaxy's steering-failure notice.
self.message_id = None;
events
}
pub(super) fn steering_started_new_turn(&mut self) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
"The ACP adapter started that steering message as a separate turn instead of \
injecting it into the active one. Galaxy terminated the adapter process immediately, \
but the turn may have begun acting; check the current terminal and file state before \
retrying.",
&mut events,
);
self.message_id = None;
events
}
pub(super) fn startup_error(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
@@ -236,6 +140,7 @@ impl AcpResponseTranslator {
if text.is_empty() {
return;
}
self.has_visible_output = true;
if let Some(message_id) = &self.message_id {
events.push(build_append_text(&self.task_id, message_id, text));
} else {
@@ -249,18 +154,64 @@ impl AcpResponseTranslator {
}
}
fn upsert_runtime_activity(
&mut self,
activity: RuntimeActivity,
events: &mut Vec<ResponseEvent>,
) -> Result<(), String> {
let activity_id = activity.id.clone();
let merged_activity = self
.activities
.entry(activity_id.clone())
.or_insert_with(|| activity.clone());
if !activity.title.trim().is_empty() {
merged_activity.title = activity.title;
}
if activity.status.is_some() {
merged_activity.status = activity.status;
}
if activity.output.is_some() {
merged_activity.output = activity.output;
}
let server_message_data = runtime_activity::encode(merged_activity)
.map_err(|error| format!("failed to encode ACP runtime activity: {error}"))?;
let fallback_text = runtime_activity_fallback_text(merged_activity);
if let Some(message_id) = self.activity_message_ids.get(&activity_id) {
events.push(build_update_runtime_activity_message(
&self.task_id,
message_id,
&fallback_text,
&server_message_data,
));
} else {
let message_id = Uuid::new_v4().to_string();
events.push(build_add_runtime_activity_message(
&self.task_id,
&message_id,
&fallback_text,
&server_message_data,
));
self.activity_message_ids.insert(activity_id, message_id);
}
self.has_visible_output = true;
Ok(())
}
fn finished(&self, stop_reason: StopReason) -> ResponseEvent {
let reason = match stop_reason {
StopReason::EndTurn | StopReason::Cancelled => {
StopReason::Completed | StopReason::Cancelled => {
stream_finished::Reason::Done(stream_finished::Done {})
}
StopReason::MaxTokens | StopReason::MaxTurnRequests => {
StopReason::MaxTokens => {
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
}
StopReason::Refusal => stream_finished::Reason::Other(stream_finished::Other {}),
// ACP marks this enum non-exhaustive so newer agents can add stop reasons
// without breaking older clients.
_ => stream_finished::Reason::Other(stream_finished::Other {}),
StopReason::ContextWindowExceeded => stream_finished::Reason::ContextWindowExceeded(
stream_finished::ContextWindowExceeded {},
),
StopReason::Refusal | StopReason::ToolLoopLimit | StopReason::Other(_) => {
stream_finished::Reason::Other(stream_finished::Other {})
}
};
let used_tokens = u32::try_from(self.used_tokens).unwrap_or(u32::MAX);
let context_usage = if self.context_size == 0 {
@@ -307,27 +258,98 @@ impl AcpResponseTranslator {
}
}
fn tool_status_line(title: &str, status: ToolCallStatus) -> String {
let status = match status {
ToolCallStatus::Pending => "waiting",
ToolCallStatus::InProgress => "running",
ToolCallStatus::Completed => "completed",
ToolCallStatus::Failed => "failed",
// ACP marks this enum non-exhaustive. Preserve a useful transcript if a
// newer agent reports a status this client does not recognize yet.
_ => "updated",
fn runtime_activity_fallback_text(activity: &RuntimeActivity) -> String {
let title = &activity.title;
let status = activity.status.as_ref().map(|status| match status {
RuntimeActivityStatus::Pending => "waiting",
RuntimeActivityStatus::InProgress => "running",
RuntimeActivityStatus::Completed => "completed",
RuntimeActivityStatus::Failed => "failed",
RuntimeActivityStatus::Other(_) => "updated",
});
let mut text = match status {
Some(status) => format!("> **{title}** — {status}"),
None => format!("> **{title}**"),
};
format!("\n\n> **{title}** — {status}\n")
if let Some(output) = &activity.output {
text.push_str("\n\n");
for line in output.lines() {
text.push_str(" ");
text.push_str(line);
text.push('\n');
}
}
text
}
fn tool_output_block(output: &str) -> String {
let mut block = String::from("\n");
for line in output.lines() {
block.push_str(" ");
block.push_str(line);
block.push('\n');
fn build_add_runtime_activity_message(
task_id: &str,
message_id: &str,
fallback_text: &str,
server_message_data: &str,
) -> ResponseEvent {
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
runtime_activity_client_action(api::client_action::Action::AddMessagesToTask(
api::client_action::AddMessagesToTask {
task_id: task_id.to_owned(),
messages: vec![message],
},
))
}
fn build_update_runtime_activity_message(
task_id: &str,
message_id: &str,
fallback_text: &str,
server_message_data: &str,
) -> ResponseEvent {
let message = runtime_activity_message(task_id, message_id, fallback_text, server_message_data);
runtime_activity_client_action(api::client_action::Action::UpdateTaskMessage(
api::client_action::UpdateTaskMessage {
task_id: task_id.to_owned(),
message: Some(message),
mask: Some(prost_types::FieldMask {
paths: vec![
"agent_output.text".to_owned(),
"server_message_data".to_owned(),
],
}),
},
))
}
fn runtime_activity_message(
task_id: &str,
message_id: &str,
fallback_text: &str,
server_message_data: &str,
) -> api::Message {
api::Message {
id: message_id.to_owned(),
task_id: task_id.to_owned(),
request_id: String::new(),
timestamp: None,
server_message_data: server_message_data.to_owned(),
citations: Vec::new(),
fetched_memories: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: fallback_text.to_owned(),
},
)),
}
}
fn runtime_activity_client_action(action: api::client_action::Action) -> ResponseEvent {
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![ClientAction {
action: Some(action),
}],
},
)),
}
block
}
#[cfg(test)]
+117 -62
View File
@@ -1,10 +1,8 @@
use galaxy_acp::{
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
ToolCallStatus,
};
use galaxy_agent_core::{AgentEvent, RuntimeActivity, RuntimeActivityStatus, StopReason};
use warp_multi_agent_api::{client_action, message, response_event};
use super::AcpResponseTranslator;
use crate::ai::agent::runtime_activity;
#[test]
fn initializes_the_existing_chat_exchange_and_persists_user_text() {
@@ -15,12 +13,8 @@ fn initializes_the_existing_chat_exchange_and_persists_user_text() {
"acp:codex".to_owned(),
);
let events = translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
.translate(AgentEvent::TurnStarted {
runtime_request_id: "session".to_owned(),
})
.expect("translate");
@@ -43,12 +37,12 @@ fn streams_agent_text_as_add_then_append() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let first = translator
.translate(AcpEvent::AgentText {
.translate(AgentEvent::TextDelta {
text: "one".to_owned(),
})
.expect("first");
let second = translator
.translate(AcpEvent::AgentText {
.translate(AgentEvent::TextDelta {
text: " two".to_owned(),
})
.expect("second");
@@ -70,15 +64,17 @@ fn streams_agent_text_as_add_then_append() {
}
#[test]
fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
fn renders_acp_tool_progress_as_structured_non_executable_activity() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Read file".to_owned(),
status: ToolCallStatus::InProgress,
output: None,
.translate(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: "tool-1".to_owned(),
title: "Read file".to_owned(),
status: Some(RuntimeActivityStatus::InProgress),
output: None,
},
})
.expect("tool");
let Some(response_event::Type::ClientActions(actions)) = &events[1].r#type else {
@@ -91,6 +87,15 @@ fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
add.messages[0].message,
Some(message::Message::AgentOutput(_))
));
assert_eq!(
runtime_activity::decode(&add.messages[0].server_message_data),
Some(RuntimeActivity {
id: "tool-1".to_owned(),
title: "Read file".to_owned(),
status: Some(RuntimeActivityStatus::InProgress),
output: None,
})
);
}
#[test]
@@ -98,15 +103,14 @@ fn maps_usage_and_successful_completion() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::Usage {
used: 25,
size: 100,
cost: None,
.translate(AgentEvent::ContextUsageUpdated {
used_tokens: 25,
context_size: 100,
})
.expect("usage");
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
.translate(AgentEvent::TurnStopped {
reason: StopReason::Completed,
})
.expect("finished");
let Some(finished) = events.iter().find_map(|event| {
@@ -129,37 +133,84 @@ fn maps_usage_and_successful_completion() {
}
#[test]
fn renders_bounded_tool_output_in_the_agent_transcript() {
fn updates_the_existing_activity_card_with_bounded_tool_output() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let started = translator
.translate(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: "tool-1".to_owned(),
title: "Run tests".to_owned(),
status: Some(RuntimeActivityStatus::InProgress),
output: None,
},
})
.expect("started");
let Some(response_event::Type::ClientActions(start_actions)) = &started[1].r#type else {
panic!("expected initial activity action");
};
let Some(client_action::Action::AddMessagesToTask(add)) = &start_actions.actions[0].action
else {
panic!("expected initial activity message");
};
let message_id = add.messages[0].id.clone();
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Run tests".to_owned(),
status: ToolCallStatus::Completed,
output: Some("test one ... ok\ntest two ... ok".to_owned()),
.translate(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: "tool-1".to_owned(),
title: "Run tests".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("test one ... ok\ntest two ... ok".to_owned()),
},
})
.expect("tool");
let Some(response_event::Type::ClientActions(status_actions)) = &events[1].r#type else {
panic!("expected status action");
let Some(response_event::Type::ClientActions(update_actions)) = &events[0].r#type else {
panic!("expected update action");
};
let Some(client_action::Action::AddMessagesToTask(add_status)) =
&status_actions.actions[0].action
let Some(client_action::Action::UpdateTaskMessage(update)) = &update_actions.actions[0].action
else {
panic!("expected status message");
panic!("expected in-place activity update");
};
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
panic!("expected agent output");
};
assert!(status.text.contains("Run tests"));
let updated_message = update.message.as_ref().expect("updated message");
assert_eq!(updated_message.id, message_id);
assert_eq!(
runtime_activity::decode(&updated_message.server_message_data),
Some(RuntimeActivity {
id: "tool-1".to_owned(),
title: "Run tests".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("test one ... ok\ntest two ... ok".to_owned()),
})
);
}
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
panic!("expected output action");
};
#[test]
fn activity_only_turn_does_not_claim_the_agent_returned_no_output() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AgentEvent::RuntimeActivityUpdated {
activity: RuntimeActivity {
id: "tool-1".to_owned(),
title: "Inspect repository".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("done".to_owned()),
},
})
.expect("activity");
let events = translator
.translate(AgentEvent::TurnStopped {
reason: StopReason::Completed,
})
.expect("finished");
assert_eq!(events.len(), 1);
assert!(matches!(
output_actions.actions[0].action,
Some(client_action::Action::AppendToMessageContent(_))
events[0].r#type,
Some(response_event::Type::Finished(_))
));
}
@@ -168,8 +219,8 @@ fn successful_turn_without_agent_output_is_still_visible() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
.translate(AgentEvent::TurnStopped {
reason: StopReason::Completed,
})
.expect("finished");
@@ -188,15 +239,13 @@ fn successful_turn_without_agent_output_is_still_visible() {
}
#[test]
fn suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
fn reasoning_is_not_flattened_into_the_plain_answer_transcript() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::UserContent {
content: ContentBlock::Text(TextContent::new(
"hidden initial prompt and system context",
)),
.translate(AgentEvent::ReasoningDelta {
text: "private chain of thought".to_owned(),
})
.expect("translate");
@@ -208,13 +257,15 @@ fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::AgentText {
.translate(AgentEvent::TextDelta {
text: "original response".to_owned(),
})
.expect("initial output");
let steered = translator
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
.translate(AgentEvent::UserInputAccepted {
text: "stop at 75s".to_owned(),
})
.expect("steering");
let Some(response_event::Type::ClientActions(user_actions)) = &steered[0].r#type else {
panic!("expected user client action");
@@ -229,7 +280,7 @@ fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
));
let resumed = translator
.translate(AcpEvent::AgentText {
.translate(AgentEvent::TextDelta {
text: "steered response".to_owned(),
})
.expect("resumed output");
@@ -247,16 +298,16 @@ fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
.translate(AgentEvent::TurnStarted {
runtime_request_id: "session".to_owned(),
})
.expect("initialize");
let events = translator.steering_failed("turn is no longer active");
let events = translator
.translate(AgentEvent::RuntimeNotice {
message: "Galaxy couldn't confirm that live steering message: turn is no longer active. The agent may not have received it; check the current terminal and file state before retrying.".to_owned(),
})
.expect("notice");
assert_eq!(events.len(), 1);
let Some(response_event::Type::ClientActions(error_actions)) = &events[0].r#type else {
@@ -279,7 +330,11 @@ fn implicit_steering_turn_warning_does_not_recommend_a_blind_retry() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator.steering_started_new_turn();
let events = translator
.translate(AgentEvent::RuntimeNotice {
message: "The ACP adapter started that steering message as a separate turn instead of injecting it into the active one. Galaxy terminated the adapter process immediately, but the turn may have begun acting; check the current terminal and file state before retrying.".to_owned(),
})
.expect("notice");
let text =
events
+56 -165
View File
@@ -1,15 +1,15 @@
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use futures::channel::oneshot;
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
use futures::stream::FusedStream as _;
use futures::{FutureExt as _, StreamExt as _};
use galaxy_acp::{
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio,
SessionConfigOptionValue, SessionId, TextContent,
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpPermissionPolicy, AcpRuntimeState,
AcpRuntimeStateHandle, AcpSessionManager, McpServer, McpServerStdio, SessionConfigOptionValue,
SessionId,
};
use galaxy_agent_core::{
turn_control, AgentRuntime as _, TurnCommand, TurnCommandSender, TurnRequest,
};
use super::launch::acp_selection_identity;
@@ -20,19 +20,7 @@ use crate::ai::agent::EntrypointType;
use crate::persistence::model::AcpConversationData;
use crate::server::server_api::AIApiError;
#[derive(Clone, Debug, Default)]
pub(crate) struct AcpSessionMetadata {
pub(crate) session_id: Option<String>,
pub(crate) can_load: bool,
pub(crate) can_steer: bool,
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
}
#[derive(Clone, Debug)]
pub(crate) struct AcpSteeringRequest {
display_text: String,
model_text: String,
}
pub(crate) type AcpSessionMetadata = AcpRuntimeState;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GalaxyMcpTarget {
@@ -41,46 +29,25 @@ pub(crate) struct GalaxyMcpTarget {
pub(crate) pane_id: String,
}
impl AcpSteeringRequest {
pub(crate) fn text(display_text: String, model_text: String) -> Self {
Self {
display_text,
model_text,
}
}
pub(crate) type AcpTurnControlSlot = Arc<Mutex<Option<TurnCommandSender>>>;
struct AcpTurnControlGuard {
slot: AcpTurnControlSlot,
}
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>;
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>;
fn pending_steering(session: AcpSessionHandle, steering: AcpSteeringRequest) -> PendingSteering {
async move {
let content = ContentBlock::Text(TextContent::new(steering.model_text));
session.steer(vec![content]).await
}
.boxed()
.fuse()
}
pub(crate) type AcpSessionHandleSlot = Arc<Mutex<Option<AcpSessionHandle>>>;
struct AcpSessionHandleGuard {
slot: AcpSessionHandleSlot,
}
impl AcpSessionHandleGuard {
fn new(slot: AcpSessionHandleSlot, session: AcpSessionHandle) -> Self {
if let Ok(mut active_session) = slot.lock() {
*active_session = Some(session);
impl AcpTurnControlGuard {
fn new(slot: AcpTurnControlSlot, control: TurnCommandSender) -> Self {
if let Ok(mut active_control) = slot.lock() {
*active_control = Some(control);
}
Self { slot }
}
}
impl Drop for AcpSessionHandleGuard {
impl Drop for AcpTurnControlGuard {
fn drop(&mut self) {
if let Ok(mut active_session) = self.slot.lock() {
*active_session = None;
if let Ok(mut active_control) = self.slot.lock() {
*active_control = None;
}
}
}
@@ -95,9 +62,8 @@ pub(crate) async fn acp_output_stream(
galaxy_terminal_interrupt_available: bool,
permission_policy: AcpPermissionPolicy,
auto_approve_permissions: bool,
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
session_handle: AcpSessionHandleSlot,
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
session_metadata: AcpRuntimeStateHandle,
turn_control_slot: AcpTurnControlSlot,
cancellation_rx: oneshot::Receiver<()>,
) -> api::ResponseStream {
let mut translator = response_translator(&params, &backend);
@@ -121,132 +87,57 @@ pub(crate) async fn acp_output_stream(
if let Some(server) = galaxy_mcp_server {
mcp_servers.push(server);
}
let request = AcpTurnRequest {
config_values: backend
.config_values
.into_iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value)
.ok()
.map(|value| (key, value))
})
.collect(),
conversation_key: conversation_id,
session_id: backend.session_id.map(SessionId::from),
cwd,
additional_directories: Vec::new(),
prompt,
mcp_servers,
auto_approve_permissions,
permission_policy,
prompt_capabilities: Default::default(),
};
let (session, events) = match manager.run_turn(request) {
Ok(turn) => turn,
let runtime_id = acp_selection_identity(&backend.agent_id, &backend.config_values);
let mut runtime_config =
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
runtime_config.config_values = backend
.config_values
.iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value.clone())
.ok()
.map(|value| (key.clone(), value))
})
.collect();
runtime_config.session_id = backend.session_id.clone().map(SessionId::from);
runtime_config.mcp_servers = mcp_servers;
runtime_config.auto_approve_permissions = auto_approve_permissions;
runtime_config.permission_policy = permission_policy;
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt);
request.conversation_id = Some(conversation_id);
let (control_sender, control) = turn_control();
let events = match runtime.start_turn(request, control).await {
Ok(events) => events,
Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
};
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone());
let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
let stream = async_stream::stream! {
let _session_handle_guard = session_handle_guard;
let _turn_control_guard = turn_control_guard;
let mut cancellation_rx = cancellation_rx.fuse();
let mut events = Box::pin(events.fuse());
let mut steering_rx = Box::pin(steering_rx.fuse());
let mut steering_queue = VecDeque::new();
let mut steering_result: PendingSteering = Fuse::terminated();
let mut events = events.fuse();
loop {
futures::select_biased! {
_ = cancellation_rx => {
if let Err(error) = session.cancel().await {
log::warn!("Failed to cancel ACP turn cleanly: {error}");
}
break;
}
steering = steering_rx.next() => {
let Some(steering) = steering else {
continue;
};
let content = ContentBlock::Text(TextContent::new(
steering.display_text.clone(),
));
match translator.translate_steered_user_content(content) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(api::StreamEvent::Response(response_event));
}
}
Err(message) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(message),
}));
break;
}
}
if steering_result.is_terminated() {
steering_result = pending_steering(session.clone(), steering);
} else {
steering_queue.push_back(steering);
}
}
steering = steering_result => {
match steering {
Ok(AcpSteeringOutcome::Injected) => {
// The user message was rendered as soon as Galaxy
// accepted it; keep consuming agent events without
// holding the transcript behind the steering RPC.
}
Ok(AcpSteeringOutcome::StartedNewTurn) => {
for response_event in translator.steering_started_new_turn() {
yield Ok(api::StreamEvent::Response(response_event));
}
}
Ok(AcpSteeringOutcome::Failed) => {
for response_event in translator.steering_failed(
"the ACP agent could not inject it into the active turn",
) {
yield Ok(api::StreamEvent::Response(response_event));
}
}
Err(error) => {
log::warn!("ACP live steering failed: {error}");
for response_event in translator.steering_failed(&error.to_string()) {
yield Ok(api::StreamEvent::Response(response_event));
}
}
}
steering_result = Fuse::terminated();
if let Some(steering) = steering_queue.pop_front() {
steering_result = pending_steering(session.clone(), steering);
} else if events.is_terminated() {
break;
if let Err(error) = control_sender.try_send(TurnCommand::Cancel) {
log::warn!("Failed to queue ACP cancellation: {error}");
}
}
event = events.next() => {
let Some(event) = event else {
if steering_result.is_terminated() && steering_queue.is_empty() {
break;
};
let event = match event {
Ok(event) => event,
Err(error) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(error),
}));
break;
}
continue;
};
if let AcpEvent::SessionStarted {
session_id,
can_load,
can_steer,
..
} = &event
{
if let Ok(mut metadata) = session_metadata.lock() {
metadata.session_id = Some(session_id.to_string());
metadata.can_load = *can_load;
metadata.can_steer = *can_steer;
}
}
if let AcpEvent::ConfigOptions { options } = &event {
if let Ok(mut metadata) = session_metadata.lock() {
metadata.config_options = options.clone();
}
}
match translator.translate(event) {
Ok(response_events) => {
for response_event in response_events {
+18 -9
View File
@@ -21,11 +21,12 @@ use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
UserQueryMode, WebFetchStatus, WebSearchStatus,
};
use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -272,10 +273,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
match message {
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message(
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
.with_citations(citations),
)),
api::message::Message::AgentOutput(output) => {
let message = if let Some(activity) =
runtime_activity::decode(&self.server_message_data)
{
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
} else {
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
};
Ok(MaybeAIAgentOutputMessage::Message(
message.with_citations(citations),
))
}
api::message::Message::AgentReasoning(reasoning) => {
let duration = reasoning
.finished_duration
+45 -1
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
@@ -11,7 +12,8 @@ use super::{
};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
runtime_activity, AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType,
StartAgentExecutionMode,
};
fn start_agent_tool_call_message(
@@ -665,3 +667,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
}
}
}
#[test]
fn structured_runtime_activity_converts_to_display_only_output() {
let activity = RuntimeActivity {
id: "tool-1".to_owned(),
title: "List directories".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("payments\nrecords_v2".to_owned()),
};
let task_id = TaskId::new("task".to_owned());
let message = api::Message {
fetched_memories: Vec::new(),
id: "message".to_owned(),
task_id: "task".to_owned(),
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
citations: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "fallback text".to_owned(),
},
)),
request_id: "request".to_owned(),
timestamp: None,
};
let converted = message
.to_client_output_message(ConversionParams {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion");
let MaybeAIAgentOutputMessage::Message(output) = converted else {
panic!("expected display output");
};
assert_eq!(
output.message,
AIAgentOutputMessageType::RuntimeActivity(activity)
);
}
+1 -1
View File
@@ -244,7 +244,7 @@ pub async fn generate_multi_agent_output(
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "none",
source: anyhow::anyhow!(
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
"No AI runtime configured. Enable an agent runtime or model provider in settings."
),
});
let (tx, rx) = async_channel::unbounded();
+21
View File
@@ -705,6 +705,27 @@ impl AIConversation {
&self.agent_backend
}
/// Updates the backend of a conversation that has not produced agent output.
///
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
/// any exchange has produced output, the backend remains stable so provider-native and
/// ACP-owned histories are never mixed.
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
matches!(
&exchange.output_status,
AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
}
)
});
if !can_change_backend && self.agent_backend != agent_backend {
return false;
}
self.agent_backend = agent_backend;
true
}
/// Records a resumable ACP session ID.
///
/// Returns `false` when called for a native provider conversation.
+9
View File
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap()
}
#[test]
fn agent_backend_does_not_change_after_successful_output() {
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
assert!(!conversation
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
}
#[test]
fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation =
+27
View File
@@ -7,6 +7,7 @@ pub(crate) mod comment;
pub(crate) mod icons;
pub(crate) mod linearization;
pub(crate) mod redaction;
pub(crate) mod runtime_activity;
pub(crate) mod task;
mod task_store;
pub(super) mod telemetry;
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use galaxy_agent_core::RuntimeActivity;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
@@ -619,6 +621,13 @@ impl AIAgentOutput {
}
}
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
result.push(activity.title.clone());
if let Some(output) = &activity.output {
result.push(output.clone());
}
last_was_action = true;
}
AIAgentOutputMessageType::TodoOperation(operation) => {
result.push(format!("{operation}"));
last_was_action = false;
@@ -1805,6 +1814,10 @@ pub enum AIAgentOutputMessageType {
token_count: Option<u32>,
},
Subagent(SubagentCall),
/// Display-only activity executed and owned by an external agent runtime.
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
/// this activity through its action executor.
RuntimeActivity(RuntimeActivity),
Action(AIAgentAction),
TodoOperation(TodoOperation),
WebSearch(WebSearchStatus),
@@ -1972,6 +1985,12 @@ impl Display for AIAgentOutputMessage {
}
}
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
AIAgentOutputMessageType::RuntimeActivity(activity) => {
write!(f, "Runtime activity: {}", activity.title)?;
if let Some(output) = &activity.output {
write!(f, "\n{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
AIAgentOutputMessageType::WebSearch(status) => match status {
@@ -2044,6 +2063,14 @@ impl AIAgentOutputMessage {
}
}
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
Self {
id,
message: AIAgentOutputMessageType::RuntimeActivity(activity),
citations: vec![],
}
}
pub fn text(id: MessageId, text: AIAgentText) -> Self {
Self {
id,
+12
View File
@@ -0,0 +1,12 @@
use galaxy_agent_core::RuntimeActivity;
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
}
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
serde_json::from_str(json).ok()
}
+8 -1
View File
@@ -432,6 +432,12 @@ pub mod text {
AIAgentActionType::RunAgents(_) => (),
AIAgentActionType::WaitForEvents { .. } => (),
},
AIAgentOutputMessageType::RuntimeActivity(activity) => {
writeln!(w, "{}", activity.title)?;
if let Some(output) = &activity.output {
writeln!(w, "{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => {
writeln!(w, "Updated TODO list:")?;
@@ -1144,7 +1150,8 @@ pub mod json {
})
}
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
| AIAgentOutputMessageType::EventsFromAgents { .. } => None,
| AIAgentOutputMessageType::EventsFromAgents { .. }
| AIAgentOutputMessageType::RuntimeActivity(_) => None,
}
}
}
+49
View File
@@ -30,6 +30,7 @@ use base64::Engine as _;
use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent};
use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
}
}
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
if is_streaming
&& has_output
&& !self.user_toggled_while_streaming
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
{
self.expand();
}
self.sync_finished_state(is_finished);
if is_finished {
if let CollapsibleExpansionState::Expanded {
scroll_pinned_to_bottom,
..
} = &mut self.expansion_state
{
*scroll_pinned_to_bottom = false;
}
}
}
/// Applies orchestration message display behavior after streaming finishes.
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
let should_auto_collapse = self.should_auto_collapse_on_finish();
@@ -2323,6 +2345,32 @@ impl AIBlock {
// Register element state for reasoning messages and track summarization timing.
for message in &output.messages {
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
let is_finished = matches!(
activity.status,
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
);
let has_output = activity
.output
.as_deref()
.is_some_and(|output| !output.is_empty());
let state = self
.collapsible_block_states
.entry(message.id.clone())
.or_insert_with(|| {
if is_streaming && has_output {
CollapsibleElementState::default()
} else {
CollapsibleElementState::collapsed()
}
});
state.sync_runtime_activity(is_streaming, is_finished, has_output);
}
if let AIAgentOutputMessageType::Reasoning {
finished_duration, ..
} = &message.message
@@ -2608,6 +2656,7 @@ impl AIBlock {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)
+3 -1
View File
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|| (is_latest_exchange
&& matches!(
message,
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_)
AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::WebSearch(_)
))
}
+10
View File
@@ -1,5 +1,6 @@
use std::time::Duration;
use galaxy_agent_core::RuntimeActivity;
use galaxy_terminal::model::escape_sequences;
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
});
assert!(!should_retain_task_output_message(&poll, false));
assert!(should_retain_task_output_message(&poll, true));
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
id: "acp-tool".to_owned(),
title: "Inspect repository".to_owned(),
status: None,
output: None,
});
assert!(!should_retain_task_output_message(&runtime_activity, false));
assert!(should_retain_task_output_message(&runtime_activity, true));
}
@@ -16,6 +16,7 @@ use ai::agent::action::{
};
use ai::agent::file_locations::group_file_contexts_for_display;
use ai::skills::{ParsedSkill, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use galaxy_core::channel::ChannelState;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
@@ -400,6 +401,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
} if !are_all_text_sections_empty(sections) => {
text_section_index += sections.len();
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
if !matches!(
activity.status,
Some(RuntimeActivityStatus::Completed)
| Some(RuntimeActivityStatus::Failed)
) {
should_render_footer = false;
should_render_suggestions = false;
}
if let Some(rendered_activity) =
render_runtime_activity(output_message, activity, props, app)
{
output_items.add_child(rendered_activity);
}
}
AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::RequestCommandOutput { .. },
id,
@@ -1262,6 +1278,119 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
output_items.finish()
}
fn render_runtime_activity(
output_message: &AIAgentOutputMessage,
activity: &RuntimeActivity,
props: Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let state = props.collapsible_block_states.get(&output_message.id)?;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_color = blended_colors::text_main(theme, theme.background());
let output = activity
.output
.as_deref()
.filter(|output| !output.is_empty());
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let title = Text::new(
activity.title.clone(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(text_color)
.with_selectable(false)
.finish();
if output.is_some() {
let chevron = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let icon_sz = icon_size(app);
let message_id = output_message.id.clone();
let mouse_state = state.expansion_toggle_mouse_state.clone();
let header = Hoverable::new(mouse_state, move |_| {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., title).finish())
.with_child(
Container::new(
ConstrainedBox::new(chevron.to_galaxyui_icon(text_color.into()).finish())
.with_width(icon_sz)
.with_height(icon_sz)
.finish(),
)
.with_margin_left(6.)
.finish(),
)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id.clone(),
));
});
content.add_child(
Flex::row()
.with_child(Shrinkable::new(1., header.finish()).finish())
.finish(),
);
} else {
content.add_child(title);
}
if let Some(output) = output {
let body = render_requested_action_body_text(
output.into(),
appearance.monospace_font_family(),
app,
)
.finish();
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
if let Some(scrollable) = render_scrollable_collapsible_content(
&output_message.id,
state,
body,
is_streaming,
320.,
) {
content.add_child(Container::new(scrollable).with_margin_top(12.).finish());
}
}
let icon = match activity.status.as_ref() {
Some(RuntimeActivityStatus::Completed) => {
inline_action_icons::green_check_icon(appearance).finish()
}
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance).finish(),
Some(RuntimeActivityStatus::Pending)
| Some(RuntimeActivityStatus::InProgress)
| Some(RuntimeActivityStatus::Other(_))
| None => galaxyui::elements::Icon::new(
Icon::ClockRefresh.into(),
internal_colors::neutral_5(appearance.theme()),
)
.finish(),
};
Some(
RenderableAction::new_with_element(content.finish(), app)
.with_icon(icon)
.render(app)
.finish(),
)
}
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
if FeatureFlag::AgentView.is_enabled() {
return false;
+48
View File
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
));
}
#[test]
fn completed_runtime_activity_stays_collapsed_until_opened() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn streaming_runtime_activity_expands_when_output_arrives() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: false,
scroll_pinned_to_bottom: true
}
));
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
}
#[test]
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn orchestration_show_and_collapse_collapses_after_finish() {
let mut state = default_collapsible_state_for_orchestration_message(
+4
View File
@@ -754,6 +754,10 @@ impl BlocklistAIController {
} => (conversation_id, task_id),
};
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.refresh_conversation_backend_without_output(conversation_id, ctx);
});
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 {
@@ -12,6 +12,8 @@ use anyhow::anyhow;
use chrono::{DateTime, Local, TimeDelta};
use futures::channel::oneshot;
#[cfg(not(target_family = "wasm"))]
use galaxy_agent_core::TurnCommand;
#[cfg(not(target_family = "wasm"))]
use galaxy_core::features::FeatureFlag;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use settings::Setting;
@@ -22,7 +24,7 @@ use warp_multi_agent_api::response_event;
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,
AcpSessionMetadata, AcpTurnControlSlot, GalaxyMcpTarget,
};
use crate::ai::agent::api::{self, ConvertToAPITypeError};
use crate::ai::agent::conversation::AIConversationId;
@@ -108,8 +110,7 @@ impl ResponseStreamId {
struct AcpRequestControl {
cancellation_rx: oneshot::Receiver<()>,
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
session_handle: AcpSessionHandleSlot,
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
turn_control: AcpTurnControlSlot,
}
/// Model wrapping an agent API response stream.
@@ -125,9 +126,7 @@ pub struct ResponseStream {
#[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>,
acp_turn_control: AcpTurnControlSlot,
params: api::RequestParams,
retry_count: usize,
/// One-time fallback from the profile's thinking model to its coding model.
@@ -198,9 +197,7 @@ impl ResponseStream {
#[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,
acp_turn_control: Arc::new(Mutex::new(None)),
params: api::RequestParams::new_for_test(),
retry_count: 0,
coding_model_fallback_attempted: false,
@@ -328,8 +325,7 @@ impl ResponseStream {
let AcpRequestControl {
cancellation_rx,
session_metadata,
session_handle,
steering_rx,
turn_control,
} = control;
let profile = BlocklistAIPermissions::as_ref(ctx)
.active_permissions_profile(ctx, params.terminal_view_id);
@@ -398,8 +394,7 @@ impl ResponseStream {
permissions.policy,
permissions.auto_approve_protocol_requests,
session_metadata,
session_handle,
steering_rx,
turn_control,
cancellation_rx,
)
.await
@@ -447,9 +442,7 @@ impl ResponseStream {
#[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();
let acp_turn_control = Arc::new(Mutex::new(None));
match &agent_backend {
AgentBackend::Provider => {
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
@@ -474,8 +467,7 @@ impl ResponseStream {
AcpRequestControl {
cancellation_rx,
session_metadata: acp_session_metadata.clone(),
session_handle: acp_session_handle.clone(),
steering_rx: acp_steering_rx,
turn_control: acp_turn_control.clone(),
},
ctx,
);
@@ -501,9 +493,7 @@ impl ResponseStream {
#[cfg(not(target_family = "wasm"))]
acp_session_metadata,
#[cfg(not(target_family = "wasm"))]
acp_session_handle,
#[cfg(not(target_family = "wasm"))]
acp_steering_tx,
acp_turn_control,
params: params.clone(),
start_time,
time_to_latest_event: TimeDelta::seconds(0),
@@ -550,18 +540,23 @@ impl ResponseStream {
|| !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()
self.acp_turn_control
.lock()
.ok()
.and_then(|control| control.clone())
.is_some_and(|control| {
control
.try_send(TurnCommand::Steer {
display_text,
model_text,
})
.is_ok()
})
}
#[cfg(target_family = "wasm")]
{
+82 -49
View File
@@ -1182,6 +1182,86 @@ impl BlocklistAIHistoryModel {
});
}
fn configured_agent_backend(
is_viewing_shared_session: bool,
is_cli_agent_transcript: bool,
ctx: &AppContext,
) -> AgentBackend {
if is_viewing_shared_session
|| is_cli_agent_transcript
|| !cfg!(unix)
|| !FeatureFlag::AgentClientProtocol.is_enabled()
{
return AgentBackend::Provider;
}
let settings = AISettings::as_ref(ctx);
if !*settings.acp_enabled.value() {
return AgentBackend::Provider;
}
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,
config_values: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) =
LLMPreferences::as_ref(ctx).selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options)
})
.unwrap_or_default(),
})
}
/// Reconciles a conversation without agent output with the currently enabled local runtime.
///
/// Agent views can create their initial conversation before the user changes runtime settings,
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
/// either case use ACP without mixing successful provider output into an ACP-owned history.
pub(crate) fn refresh_conversation_backend_without_output(
&mut self,
conversation_id: AIConversationId,
ctx: &AppContext,
) {
let Some(conversation) = self.conversation(&conversation_id) else {
return;
};
let agent_backend = Self::configured_agent_backend(
conversation.is_viewing_shared_session(),
conversation.is_cli_agent_transcript(),
ctx,
);
if conversation.agent_backend() == &agent_backend {
return;
}
if let Some(conversation) = self.conversation_mut(&conversation_id) {
conversation.set_agent_backend_if_no_output(agent_backend);
}
}
/// Starts a new conversation in the given terminal surface's history, effectively marking the
/// existing conversation (if any) as completed.
///
@@ -1197,55 +1277,8 @@ impl BlocklistAIHistoryModel {
is_cli_agent_transcript: bool,
ctx: &mut ModelContext<Self>,
) -> AIConversationId {
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,
config_values: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) = LLMPreferences::as_ref(ctx)
.selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(
&agent.config_options,
)
})
.unwrap_or_default(),
})
} else {
AgentBackend::Provider
}
} else {
AgentBackend::Provider
};
let agent_backend =
Self::configured_agent_backend(is_viewing_shared_session, is_cli_agent_transcript, ctx);
let mut new_conversation = AIConversation::new_with_agent_backend(
is_viewing_shared_session,
is_cli_agent_transcript,
@@ -88,6 +88,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
});
}
#[test]
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
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(false, ctx)
.expect("ACP setting should update");
});
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, _| {
assert_eq!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
&AgentBackend::Provider
);
});
history_model.update(&mut app, |model, _| {
let now = Local::now();
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(AIAgentExchange {
id: AIAgentExchangeId::new(),
input: Vec::new(),
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error {
output: None,
error: RenderableAIError::other("No AI provider configured", true),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("none"),
request_cost: None,
coding_model_id: LLMId::from("none"),
cli_agent_model_id: LLMId::from("none"),
computer_use_model_id: LLMId::from("none"),
response_initiator: None,
});
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(true, ctx)
.expect("ACP setting should update");
});
history_model.update(&mut app, |model, ctx| {
model.refresh_conversation_backend_without_output(conversation_id, ctx);
});
history_model.read(&app, |model, _| {
assert!(matches!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
AgentBackend::Acp(_)
));
});
});
}
/// Helper function to create a PersistedAIInput for testing
fn create_persisted_query(
query_text: &str,
@@ -469,6 +469,7 @@ impl OrchestrationEventService {
| AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_)
+15 -2
View File
@@ -421,7 +421,7 @@ impl AvailableLLMs {
request_multiplier: 1,
credit_multiplier: None,
},
description: Some("Enable Bedrock or OpenAI/LiteLLM in settings".to_string()),
description: Some("Enable an AI runtime in Models settings".to_string()),
disable_reason: Some(DisableReason::Unavailable),
vision_supported: false,
spec: None,
@@ -524,7 +524,7 @@ impl Default for ModelsByFeature {
request_multiplier: 1,
credit_multiplier: None,
},
description: Some("Enable Bedrock or OpenAI/LiteLLM in settings".to_string()),
description: Some("Enable an AI runtime in Models settings".to_string()),
disable_reason: None,
vision_supported: false,
spec: None,
@@ -639,6 +639,7 @@ impl LLMPreferences {
event,
AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::OpenAIEnabled { .. }
| AISettingsChangedEvent::AcpEnabled { .. }
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
| AISettingsChangedEvent::OpenAIApiKey { .. }
| AISettingsChangedEvent::OpenAIModels { .. }
@@ -1047,8 +1048,20 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))]
fn inject_acp_models(&mut self, ctx: &AppContext) {
let previous_acp_model_ids = self.acp_selections.keys().cloned().collect::<HashSet<_>>();
let remove_previous_acp_models = |choices: &mut Vec<LLMInfo>| {
choices.retain(|model| !previous_acp_model_ids.contains(&model.id));
};
remove_previous_acp_models(&mut self.models_by_feature.agent_mode.choices);
remove_previous_acp_models(&mut self.models_by_feature.coding.choices);
if let Some(cli) = &mut self.models_by_feature.cli_agent {
remove_previous_acp_models(&mut cli.choices);
}
self.acp_selections.clear();
let settings = AISettings::as_ref(ctx);
if !*settings.acp_enabled.value() {
return;
}
for agent in settings.acp_agents.value() {
let model_option = agent
.config_options
+87 -1
View File
@@ -10,7 +10,9 @@ use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings::OpenAIModelConfig;
use crate::settings::{
AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, OpenAIModelConfig,
};
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
@@ -189,6 +191,90 @@ fn codex_models_reject_system_messages_even_with_stale_true_metadata() {
assert!(!model.supports_system_messages());
}
#[test]
fn acp_models_are_injected_only_while_acp_is_enabled() {
App::test((), |mut app| async move {
initialize_settings_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_agents
.set_value(
vec![AcpAgentSettings {
id: "codex".to_owned(),
name: "Codex".to_owned(),
version: None,
description: None,
icon_url: None,
capabilities: Vec::new(),
config_options: vec![AcpConfigOptionSettings {
id: "model".to_owned(),
name: "Model".to_owned(),
description: None,
category: Some("model".to_owned()),
kind: "select".to_owned(),
current_value: serde_json::json!("gpt-test"),
options: vec![AcpConfigValueSettings {
value: serde_json::json!("gpt-test"),
name: "GPT Test".to_owned(),
description: None,
}],
}],
discovery_timestamp: None,
discovery_source: None,
discovery_error: None,
}],
ctx,
)
.expect("ACP agents should update");
});
let mut preferences = LLMPreferences {
models_by_feature: ModelsByFeature::default(),
last_update: None,
base_llm_for_terminal_view: HashMap::new(),
custom_llms: Vec::new(),
custom_model_routers: Vec::new(),
openai_provider_routing: HashMap::new(),
fetched_openai_models: Vec::new(),
acp_selections: HashMap::new(),
};
app.read(|ctx| preferences.inject_acp_models(ctx));
app.read(|ctx| preferences.inject_acp_models(ctx));
assert_eq!(preferences.acp_selections.len(), 1);
assert_eq!(
preferences
.models_by_feature
.agent_mode
.choices
.iter()
.filter(|model| model.id.as_str().starts_with("acp:"))
.count(),
1
);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(false, ctx)
.expect("ACP setting should update");
});
app.read(|ctx| preferences.inject_acp_models(ctx));
assert!(preferences.acp_selections.is_empty());
assert!(preferences
.models_by_feature
.agent_mode
.choices
.iter()
.all(|model| !model.id.as_str().starts_with("acp:")));
});
}
#[test]
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
let manual = openai_model("manual-model");
+10
View File
@@ -305,6 +305,16 @@ where
), stream_type));
return;
}
AgentEvent::RuntimeActivityUpdated { .. }
| AgentEvent::ContextUsageUpdated { .. }
| AgentEvent::UserInputAccepted { .. }
| AgentEvent::RuntimeNotice { .. } => {
yield Err(agent_error(AgentError::new(
galaxy_agent_core::AgentErrorKind::Protocol,
"the provider runtime emitted a session-runtime event",
), stream_type));
return;
}
}
}
}
+14 -3
View File
@@ -2091,9 +2091,20 @@ impl AISettings {
pub fn is_any_ai_enabled(&self, app: &AppContext) -> bool {
// Galaxy does not require Warp authentication for AI.
// AI is enabled as long as the user hasn't explicitly disabled it
// and there's no org policy blocking it.
*self.is_any_ai_enabled && !self.is_ai_disabled_due_to_remote_session_org_policy(app)
// AI is enabled only when the user hasn't explicitly disabled it, at least one local
// runtime is enabled, and there's no org policy blocking it.
*self.is_any_ai_enabled
&& self.has_enabled_ai_runtime()
&& !self.is_ai_disabled_due_to_remote_session_org_policy(app)
}
/// Returns whether Galaxy has a local model provider or agent runtime enabled.
pub fn has_enabled_ai_runtime(&self) -> bool {
*self.bedrock_enabled.value()
|| *self.openai_enabled.value()
|| (cfg!(unix)
&& FeatureFlag::AgentClientProtocol.is_enabled()
&& *self.acp_enabled.value())
}
pub fn default_session_mode(&self, app: &AppContext) -> DefaultSessionMode {
+41
View File
@@ -422,6 +422,47 @@ fn orchestration_is_enabled_when_ai_is_enabled() {
});
}
#[test]
fn ai_is_disabled_without_an_enabled_runtime() {
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
add_ai_enablement_dependencies_for_test(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.bedrock_enabled
.set_value(false, ctx)
.expect("Bedrock setting should update");
settings
.openai_enabled
.set_value(false, ctx)
.expect("OpenAI setting should update");
settings
.acp_enabled
.set_value(false, ctx)
.expect("ACP setting should update");
});
AISettings::handle(&app).read(&app, |settings, ctx| {
assert!(!settings.has_enabled_ai_runtime());
assert!(!settings.is_any_ai_enabled(ctx));
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(true, ctx)
.expect("ACP setting should update");
});
AISettings::handle(&app).read(&app, |settings, ctx| {
assert!(settings.has_enabled_ai_runtime());
assert!(settings.is_any_ai_enabled(ctx));
});
});
}
#[test]
fn test_should_display_quota_reset_banner_with_empty_history() {
App::test((), |mut app| async move {
+19 -13
View File
@@ -1973,7 +1973,7 @@ impl AISettingsPageView {
widgets.push(Box::new(CloudHandoffWidget::default()));
widgets.push(Box::new(CLIAgentWidget::default()));
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx)));
}
widgets.push(Box::new(AgentAttributionWidget::default()));
widgets.push(Box::new(OtherAIWidget::default()));
@@ -2011,9 +2011,6 @@ impl AISettingsPageView {
widgets.push(Box::new(VoiceWidget::default()));
}
widgets.push(Box::new(CloudHandoffWidget::default()));
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
widgets.push(Box::new(ACPSettingsWidget::new(ctx)));
}
if FeatureFlag::CustomModelRouters.is_enabled() {
widgets.push(Box::new(CustomModelRoutersWidget));
}
@@ -2038,6 +2035,9 @@ impl AISettingsPageView {
widgets.push(Box::new(ModelsOverviewWidget));
widgets.push(Box::new(OpenAISettingsWidget::new(ctx)));
widgets.push(Box::new(BedrockSettingsWidget::new(ctx)));
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx)));
}
let title: Option<&str> = None;
return (PageType::new_uncategorized(widgets, title), None);
}
@@ -7268,7 +7268,7 @@ impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"models providers rig litellm openai compatible ollama lm studio bedrock"
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol"
}
fn render(
@@ -7286,18 +7286,19 @@ impl SettingsWidget for ModelsOverviewWidget {
.map(|provider| provider.models.len())
.sum::<usize>();
let bedrock_model_count = settings.bedrock_models.value().len();
let agent_runtime_count = usize::from(*settings.acp_enabled.value());
Flex::column()
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure the model providers available to Galaxy. OpenAI-compatible endpoints and opted-in Bedrock models share the same Rig conversation, tool, and UI runtime; Bedrock's compatibility path remains available during validation.",
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible endpoints and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s)."
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
),
true,
app,
@@ -7811,7 +7812,7 @@ impl SettingsWidget for BedrockSettingsWidget {
}
}
struct ACPSettingsWidget {
struct AgentRuntimeSettingsWidget {
enabled_toggle: SwitchStateHandle,
agent_id_editor: ViewHandle<EditorView>,
command_editor: ViewHandle<EditorView>,
@@ -7820,7 +7821,7 @@ struct ACPSettingsWidget {
refresh_button: ViewHandle<ActionButton>,
}
impl ACPSettingsWidget {
impl AgentRuntimeSettingsWidget {
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
let settings = AISettings::as_ref(ctx);
let is_enabled = *settings.acp_enabled.value();
@@ -7988,11 +7989,11 @@ impl ACPSettingsWidget {
}
}
impl SettingsWidget for ACPSettingsWidget {
impl SettingsWidget for AgentRuntimeSettingsWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"acp agent client protocol codex opencode subscription local agent"
"agent runtime acp agent client protocol codex opencode subscription local"
}
fn should_render(&self, _app: &AppContext) -> bool {
@@ -8014,9 +8015,14 @@ impl SettingsWidget for ACPSettingsWidget {
.discovery_state()
.clone();
column.add_child(build_sub_header(appearance, "Agent Client Protocol", None).finish());
column.add_child(build_sub_header(appearance, "Agent runtimes", None).finish());
column.add_child(render_ai_setting_description(
"Connect session-oriented coding agents through the Agent Client Protocol. Agent runtimes are not model providers: Galaxy supplies the workspace UI and safety boundary while the agent owns its model loop and session.",
true,
app,
));
column.add_child(render_ai_setting_toggle::<AcpEnabled>(
"Use an ACP agent for new conversations",
"Enable ACP agent runtime",
AISettingsPageAction::ToggleAcpEnabled,
is_enabled,
true,
+8 -3
View File
@@ -363,9 +363,14 @@ impl FromStr for SettingsSection {
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
"Knowledge" => Ok(Self::Knowledge),
"Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents),
"Models" | "AWS Bedrock" | "Bedrock" | "OpenAI / LiteLLM" | "OpenAI" => {
Ok(Self::Models)
}
"Models"
| "AWS Bedrock"
| "Bedrock"
| "OpenAI / LiteLLM"
| "OpenAI"
| "Agent runtimes"
| "Agent Client Protocol"
| "ACP" => Ok(Self::Models),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
"Experiments" => Ok(Self::Experiments),
+3
View File
@@ -114,6 +114,9 @@ fn legacy_settings_names_remain_parseable() {
("Bedrock", SettingsSection::Models),
("OpenAI / LiteLLM", SettingsSection::Models),
("OpenAI", SettingsSection::Models),
("Agent runtimes", SettingsSection::Models),
("Agent Client Protocol", SettingsSection::Models),
("ACP", SettingsSection::Models),
("CodeIndexing", SettingsSection::CodeIndexing),
("EditorAndCodeReview", SettingsSection::EditorAndCodeReview),
] {
@@ -113,6 +113,9 @@ impl SlashCommandDataSource {
if matches!(
event,
AISettingsChangedEvent::IsAnyAIEnabled { .. }
| AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::OpenAIEnabled { .. }
| AISettingsChangedEvent::AcpEnabled { .. }
| AISettingsChangedEvent::ShouldForceDisableCloudHandoff { .. }
) {
me.recompute_active_commands(ctx);