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 {