ACP Wrap up
This commit is contained in:
@@ -7,7 +7,6 @@
|
||||
mod launch;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod response_translator;
|
||||
mod runtime_model;
|
||||
mod transport;
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
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, 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,
|
||||
};
|
||||
|
||||
/// Stateful translation from ACP session updates to Galaxy's existing agent UI
|
||||
/// response protocol.
|
||||
pub(super) struct AcpResponseTranslator {
|
||||
task_id: String,
|
||||
request_id: String,
|
||||
needs_create_task: bool,
|
||||
user_query: Option<String>,
|
||||
model_id: String,
|
||||
initialized: bool,
|
||||
message_id: Option<String>,
|
||||
activity_message_ids: HashMap<String, String>,
|
||||
activities: HashMap<String, RuntimeActivity>,
|
||||
has_visible_output: bool,
|
||||
used_tokens: u64,
|
||||
context_size: u64,
|
||||
}
|
||||
|
||||
impl AcpResponseTranslator {
|
||||
pub(super) fn new(
|
||||
task_id: String,
|
||||
needs_create_task: bool,
|
||||
user_query: Option<String>,
|
||||
model_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
needs_create_task,
|
||||
user_query,
|
||||
model_id,
|
||||
initialized: false,
|
||||
message_id: None,
|
||||
activity_message_ids: HashMap::new(),
|
||||
activities: HashMap::new(),
|
||||
has_visible_output: false,
|
||||
used_tokens: 0,
|
||||
context_size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn translate(&mut self, event: AgentEvent) -> Result<Vec<ResponseEvent>, String> {
|
||||
let mut events = Vec::new();
|
||||
match event {
|
||||
AgentEvent::TurnStarted { .. } => self.initialize(&mut events),
|
||||
AgentEvent::TextDelta { text } => {
|
||||
self.initialize(&mut events);
|
||||
self.add_or_append(&text, &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)?;
|
||||
}
|
||||
AgentEvent::ContextUsageUpdated {
|
||||
used_tokens,
|
||||
context_size,
|
||||
} => {
|
||||
self.used_tokens = used_tokens;
|
||||
self.context_size = context_size;
|
||||
}
|
||||
AgentEvent::UserInputAccepted { text } => {
|
||||
self.initialize(&mut events);
|
||||
events.push(build_user_query_message(&self.task_id, &text));
|
||||
self.message_id = None;
|
||||
}
|
||||
AgentEvent::RuntimeNotice { message } => {
|
||||
self.initialize(&mut events);
|
||||
self.message_id = None;
|
||||
self.add_or_append(&message, &mut events);
|
||||
self.message_id = None;
|
||||
}
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
self.initialize(&mut events);
|
||||
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(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());
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(super) fn startup_error(&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 start the ACP agent: {error}"),
|
||||
&mut events,
|
||||
);
|
||||
events.push(self.finished(StopReason::Refusal));
|
||||
events
|
||||
}
|
||||
|
||||
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
|
||||
if self.initialized {
|
||||
return;
|
||||
}
|
||||
// The ACP session ID is persisted separately. An empty conversation ID
|
||||
// keeps this synthetic Init event out of Galaxy cloud token paths.
|
||||
events.push(build_stream_init(&self.request_id, ""));
|
||||
if self.needs_create_task {
|
||||
events.push(build_create_task(&self.task_id));
|
||||
}
|
||||
if let Some(user_query) = &self.user_query {
|
||||
events.push(build_user_query_message(&self.task_id, user_query));
|
||||
}
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
fn add_or_append(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
|
||||
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 {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
events.push(build_add_agent_output_message(
|
||||
&self.task_id,
|
||||
&message_id,
|
||||
text,
|
||||
));
|
||||
self.message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
|
||||
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::Completed | StopReason::Cancelled => {
|
||||
stream_finished::Reason::Done(stream_finished::Done {})
|
||||
}
|
||||
StopReason::MaxTokens => {
|
||||
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
|
||||
}
|
||||
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 {
|
||||
0.0
|
||||
} else {
|
||||
(self.used_tokens as f32 / self.context_size as f32).clamp(0.0, 1.0)
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
let usage_metadata = stream_finished::ConversationUsageMetadata {
|
||||
context_window_usage: context_usage,
|
||||
summarized: false,
|
||||
credits_spent: 0.0,
|
||||
platform_credits_spent: 0.0,
|
||||
total_input_tokens: used_tokens,
|
||||
token_usage: Vec::new(),
|
||||
tool_usage_metadata: None,
|
||||
warp_token_usage: HashMap::new(),
|
||||
byok_token_usage: HashMap::new(),
|
||||
custom_endpoint_token_usage: HashMap::new(),
|
||||
context_window_segments: Vec::new(),
|
||||
};
|
||||
ResponseEvent {
|
||||
r#type: Some(api::response_event::Type::Finished(
|
||||
api::response_event::StreamFinished {
|
||||
reason: Some(reason),
|
||||
token_usage: vec![stream_finished::TokenUsage {
|
||||
model_id: self.model_id.clone(),
|
||||
// ACP reports current context occupancy, not the input
|
||||
// consumed by this individual request. Galaxy separately
|
||||
// accumulates per-request token usage, so counting it
|
||||
// here would grow the total again on every turn.
|
||||
total_input: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_write: 0,
|
||||
cost_in_cents: 0.0,
|
||||
}],
|
||||
should_refresh_model_config: false,
|
||||
request_cost: None,
|
||||
conversation_usage_metadata: Some(usage_metadata),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}**"),
|
||||
};
|
||||
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 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),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "response_translator_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,411 +0,0 @@
|
||||
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() {
|
||||
let mut translator = AcpResponseTranslator::new(
|
||||
"task".to_owned(),
|
||||
true,
|
||||
Some("hello".to_owned()),
|
||||
"acp:codex".to_owned(),
|
||||
);
|
||||
let events = translator
|
||||
.translate(AgentEvent::TurnStarted {
|
||||
runtime_request_id: "session".to_owned(),
|
||||
})
|
||||
.expect("translate");
|
||||
|
||||
assert!(matches!(
|
||||
events[0].r#type,
|
||||
Some(response_event::Type::Init(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
events[1].r#type,
|
||||
Some(response_event::Type::ClientActions(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2].r#type,
|
||||
Some(response_event::Type::ClientActions(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(AgentEvent::TextDelta {
|
||||
text: "one".to_owned(),
|
||||
})
|
||||
.expect("first");
|
||||
let second = translator
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: " two".to_owned(),
|
||||
})
|
||||
.expect("second");
|
||||
|
||||
let Some(response_event::Type::ClientActions(first_actions)) = &first[1].r#type else {
|
||||
panic!("expected first client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
first_actions.actions[0].action,
|
||||
Some(client_action::Action::AddMessagesToTask(_))
|
||||
));
|
||||
let Some(response_event::Type::ClientActions(second_actions)) = &second[0].r#type else {
|
||||
panic!("expected append client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
second_actions.actions[0].action,
|
||||
Some(client_action::Action::AppendToMessageContent(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(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 {
|
||||
panic!("expected client action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
||||
panic!("expected display-only message");
|
||||
};
|
||||
assert!(matches!(
|
||||
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]
|
||||
fn maps_usage_and_successful_completion() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AgentEvent::ContextUsageUpdated {
|
||||
used_tokens: 25,
|
||||
context_size: 100,
|
||||
})
|
||||
.expect("usage");
|
||||
let events = translator
|
||||
.translate(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("finished");
|
||||
let Some(finished) = events.iter().find_map(|event| {
|
||||
let Some(response_event::Type::Finished(finished)) = &event.r#type else {
|
||||
return None;
|
||||
};
|
||||
Some(finished)
|
||||
}) else {
|
||||
panic!("expected finished");
|
||||
};
|
||||
assert_eq!(finished.token_usage[0].total_input, 0);
|
||||
assert_eq!(
|
||||
finished
|
||||
.conversation_usage_metadata
|
||||
.as_ref()
|
||||
.expect("metadata")
|
||||
.context_window_usage,
|
||||
0.25
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(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(update_actions)) = &events[0].r#type else {
|
||||
panic!("expected update action");
|
||||
};
|
||||
let Some(client_action::Action::UpdateTaskMessage(update)) = &update_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected in-place activity update");
|
||||
};
|
||||
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()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
events[0].r#type,
|
||||
Some(response_event::Type::Finished(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(AgentEvent::TurnStopped {
|
||||
reason: StopReason::Completed,
|
||||
})
|
||||
.expect("finished");
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
let Some(response_event::Type::ClientActions(actions)) = &event.r#type else {
|
||||
return false;
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
|
||||
return false;
|
||||
};
|
||||
let Some(message::Message::AgentOutput(output)) = &add.messages[0].message else {
|
||||
return false;
|
||||
};
|
||||
output.text.contains("completed without a text response")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(AgentEvent::ReasoningDelta {
|
||||
text: "private chain of thought".to_owned(),
|
||||
})
|
||||
.expect("translate");
|
||||
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(AgentEvent::TextDelta {
|
||||
text: "original response".to_owned(),
|
||||
})
|
||||
.expect("initial output");
|
||||
|
||||
let steered = translator
|
||||
.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");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add_user)) = &user_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected user message");
|
||||
};
|
||||
assert!(matches!(
|
||||
add_user.messages[0].message,
|
||||
Some(message::Message::UserQuery(_))
|
||||
));
|
||||
|
||||
let resumed = translator
|
||||
.translate(AgentEvent::TextDelta {
|
||||
text: "steered response".to_owned(),
|
||||
})
|
||||
.expect("resumed output");
|
||||
let Some(response_event::Type::ClientActions(agent_actions)) = &resumed[0].r#type else {
|
||||
panic!("expected agent client action");
|
||||
};
|
||||
assert!(matches!(
|
||||
agent_actions.actions[0].action,
|
||||
Some(client_action::Action::AddMessagesToTask(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
|
||||
let mut translator =
|
||||
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
|
||||
translator
|
||||
.translate(AgentEvent::TurnStarted {
|
||||
runtime_request_id: "session".to_owned(),
|
||||
})
|
||||
.expect("initialize");
|
||||
|
||||
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 {
|
||||
panic!("expected visible error action");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(add_error)) =
|
||||
&error_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected visible error message");
|
||||
};
|
||||
let Some(message::Message::AgentOutput(output)) = &add_error.messages[0].message else {
|
||||
panic!("expected agent output");
|
||||
};
|
||||
assert!(output.text.contains("couldn't confirm"));
|
||||
assert!(output.text.contains("before retrying"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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
|
||||
.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
|
||||
.iter()
|
||||
.filter_map(|event| match &event.r#type {
|
||||
Some(response_event::Type::ClientActions(actions)) => actions
|
||||
.actions
|
||||
.iter()
|
||||
.find_map(|action| match &action.action {
|
||||
Some(client_action::Action::AddMessagesToTask(add)) => add
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|message| match &message.message {
|
||||
Some(message::Message::AgentOutput(output)) => {
|
||||
Some(output.text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
assert!(text.contains("started"));
|
||||
assert!(text.contains("terminated"));
|
||||
assert!(text.contains("immediately"));
|
||||
assert!(text.contains("may have begun acting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_error_keeps_the_user_request_and_finishes_visibly() {
|
||||
let mut translator = AcpResponseTranslator::new(
|
||||
"task".to_owned(),
|
||||
false,
|
||||
Some("help me".to_owned()),
|
||||
"acp:codex".to_owned(),
|
||||
);
|
||||
|
||||
let events = translator.startup_error("adapter missing");
|
||||
|
||||
assert_eq!(events.len(), 4);
|
||||
assert!(matches!(
|
||||
events[0].r#type,
|
||||
Some(response_event::Type::Init(_))
|
||||
));
|
||||
let Some(response_event::Type::ClientActions(user_actions)) = &events[1].r#type else {
|
||||
panic!("expected visible user request");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(user_messages)) =
|
||||
&user_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected user message");
|
||||
};
|
||||
assert!(matches!(
|
||||
user_messages.messages[0].message,
|
||||
Some(message::Message::UserQuery(_))
|
||||
));
|
||||
let Some(response_event::Type::ClientActions(error_actions)) = &events[2].r#type else {
|
||||
panic!("expected visible startup error");
|
||||
};
|
||||
let Some(client_action::Action::AddMessagesToTask(error_messages)) =
|
||||
&error_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected error message");
|
||||
};
|
||||
let Some(message::Message::AgentOutput(output)) = &error_messages.messages[0].message else {
|
||||
panic!("expected agent output");
|
||||
};
|
||||
assert!(output.text.contains("adapter missing"));
|
||||
assert!(matches!(
|
||||
events[3].r#type,
|
||||
Some(response_event::Type::Finished(_))
|
||||
));
|
||||
}
|
||||
@@ -9,14 +9,15 @@ use galaxy_acp::{
|
||||
SessionId,
|
||||
};
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentRuntime as _, TurnCommand, TurnCommandSender, TurnRequest,
|
||||
turn_control, AgentRuntime as _, RuntimeCapabilities, TurnCommand, TurnCommandSender,
|
||||
TurnRequest,
|
||||
};
|
||||
|
||||
use super::launch::acp_selection_identity;
|
||||
use super::prompt::{prompt_content, GalaxyTerminalTools};
|
||||
use super::response_translator::AcpResponseTranslator;
|
||||
use crate::ai::agent::api::{self, RequestParams};
|
||||
use crate::ai::agent::EntrypointType;
|
||||
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::persistence::model::AcpConversationData;
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
@@ -170,18 +171,24 @@ pub(crate) fn acp_startup_error_stream(
|
||||
fn response_translator(
|
||||
params: &RequestParams,
|
||||
backend: &AcpConversationData,
|
||||
) -> AcpResponseTranslator {
|
||||
) -> RuntimeResponseTranslator {
|
||||
let task_id = params
|
||||
.root_task_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let user_query = request_user_query(params);
|
||||
AcpResponseTranslator::new(
|
||||
RuntimeResponseTranslator::new(RuntimeResponseConfig {
|
||||
task_id,
|
||||
params.tasks.is_empty(),
|
||||
// ACP owns its session identifier. Keeping this empty prevents the
|
||||
// compatibility Init event from entering Galaxy cloud-token paths.
|
||||
conversation_id: String::new(),
|
||||
needs_create_task: params.tasks.is_empty(),
|
||||
user_query,
|
||||
acp_selection_identity(&backend.agent_id, &backend.config_values),
|
||||
)
|
||||
model_id: acp_selection_identity(&backend.agent_id, &backend.config_values),
|
||||
max_context_tokens: None,
|
||||
capabilities: RuntimeCapabilities::session_runtime(),
|
||||
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_user_query(params: &RequestParams) -> Option<String> {
|
||||
@@ -250,10 +257,11 @@ fn galaxy_mcp_args(
|
||||
}
|
||||
|
||||
fn translated_startup_error_stream(
|
||||
mut translator: AcpResponseTranslator,
|
||||
mut translator: RuntimeResponseTranslator,
|
||||
message: &str,
|
||||
) -> api::ResponseStream {
|
||||
let events = translator.startup_error(message);
|
||||
let events =
|
||||
translator.startup_error(&format!("Galaxy couldn't start the ACP agent: {message}"));
|
||||
Box::pin(futures::stream::iter(
|
||||
events
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user