Add ACP agent backend and terminal controls
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
use galaxy_acp::{
|
||||
AcpEvent, ContentBlock, SessionId, StopReason, TextContent, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
use warp_multi_agent_api::{client_action, message, response_event};
|
||||
|
||||
use super::AcpResponseTranslator;
|
||||
|
||||
#[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(AcpEvent::SessionStarted {
|
||||
session_id: SessionId::from("session"),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
})
|
||||
.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(AcpEvent::AgentText {
|
||||
text: "one".to_owned(),
|
||||
})
|
||||
.expect("first");
|
||||
let second = translator
|
||||
.translate(AcpEvent::AgentText {
|
||||
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_text_not_an_executable_galaxy_action() {
|
||||
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,
|
||||
})
|
||||
.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(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
})
|
||||
.expect("usage");
|
||||
let events = translator
|
||||
.translate(AcpEvent::Finished {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
.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 renders_bounded_tool_output_in_the_agent_transcript() {
|
||||
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: "Run tests".to_owned(),
|
||||
status: ToolCallStatus::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(client_action::Action::AddMessagesToTask(add_status)) =
|
||||
&status_actions.actions[0].action
|
||||
else {
|
||||
panic!("expected status message");
|
||||
};
|
||||
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
|
||||
panic!("expected agent output");
|
||||
};
|
||||
assert!(status.text.contains("Run tests"));
|
||||
|
||||
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
|
||||
panic!("expected output action");
|
||||
};
|
||||
assert!(matches!(
|
||||
output_actions.actions[0].action,
|
||||
Some(client_action::Action::AppendToMessageContent(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[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(AcpEvent::Finished {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
.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 suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
|
||||
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",
|
||||
)),
|
||||
})
|
||||
.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(AcpEvent::AgentText {
|
||||
text: "original response".to_owned(),
|
||||
})
|
||||
.expect("initial output");
|
||||
|
||||
let steered = translator
|
||||
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
|
||||
.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(AcpEvent::AgentText {
|
||||
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(AcpEvent::SessionStarted {
|
||||
session_id: SessionId::from("session"),
|
||||
can_load: true,
|
||||
can_steer: true,
|
||||
})
|
||||
.expect("initialize");
|
||||
|
||||
let events = translator.steering_failed("turn is no longer active");
|
||||
|
||||
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.steering_started_new_turn();
|
||||
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user