Complete Rig tool lifecycle migration

This commit is contained in:
2026-08-04 16:00:20 -05:00
parent 91d8bd0381
commit a3c68e9c30
30 changed files with 1494 additions and 176 deletions
+10 -6
View File
@@ -172,7 +172,7 @@ pub(crate) async fn acp_output_stream(
match translator.translate_steered_user_content(content) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(response_event);
yield Ok(api::StreamEvent::Response(response_event));
}
}
Err(message) => {
@@ -198,20 +198,20 @@ pub(crate) async fn acp_output_stream(
}
Ok(AcpSteeringOutcome::StartedNewTurn) => {
for response_event in translator.steering_started_new_turn() {
yield Ok(response_event);
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(response_event);
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(response_event);
yield Ok(api::StreamEvent::Response(response_event));
}
}
}
@@ -250,7 +250,7 @@ pub(crate) async fn acp_output_stream(
match translator.translate(event) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(response_event);
yield Ok(api::StreamEvent::Response(response_event));
}
}
Err(message) => {
@@ -363,7 +363,11 @@ fn translated_startup_error_stream(
message: &str,
) -> api::ResponseStream {
let events = translator.startup_error(message);
Box::pin(futures::stream::iter(events.into_iter().map(Ok)))
Box::pin(futures::stream::iter(
events
.into_iter()
.map(|event| Ok(api::StreamEvent::Response(event))),
))
}
#[cfg(test)]
+20 -2
View File
@@ -24,7 +24,7 @@ use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output;
use serde::Serialize;
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
use super::{AIAgentAction, AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext};
@@ -162,17 +162,35 @@ pub struct RequestParams {
pub global_rules: Vec<(String, String)>,
}
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
/// Provider/runtime events consumed by the local conversation controller.
///
/// The legacy response envelope remains at the UI boundary while the local Rig runtime emits
/// executable tool proposals directly as Galaxy domain actions. This avoids translating Rig tool
/// calls into protobuf only to immediately translate them back before execution.
#[derive(Debug)]
pub enum StreamEvent {
Response(warp_multi_agent_api::ResponseEvent),
ToolProposed(AIAgentAction),
}
pub type Event = Result<StreamEvent, Arc<AIApiError>>;
pub type LegacyEvent = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
#[cfg(not(target_family = "wasm"))]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>;
#[cfg(not(target_family = "wasm"))]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent> + Send + 'static>>;
// The WASM version of this type has no bound on `Send`, which is an unnecessary bound when
// targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async
// execution in WoW).
#[cfg(target_family = "wasm")]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>;
#[cfg(target_family = "wasm")]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent>>>;
#[derive(Debug, Clone)]
pub struct ConversationData {
pub id: AIConversationId,
+7 -3
View File
@@ -6,7 +6,7 @@ use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
use super::{ConvertToAPITypeError, RequestParams, ResponseStream, StreamEvent};
use crate::ai::agent::redaction;
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
@@ -164,7 +164,9 @@ pub async fn generate_multi_agent_output(
match openai_translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
let output_stream = stream
.map(|event| event.map(StreamEvent::Response))
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
@@ -196,7 +198,9 @@ pub async fn generate_multi_agent_output(
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
let output_stream = stream
.map(|event| event.map(StreamEvent::Response))
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
+41
View File
@@ -2227,6 +2227,47 @@ impl AIConversation {
Ok(())
}
pub fn apply_domain_tool_proposal(
&mut self,
stream_id: &ResponseStreamId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
let added_exchanges = self
.added_exchanges_by_response
.get(stream_id)
.ok_or(UpdateConversationError::NoPendingRequest)?;
let exchange_id = added_exchanges
.iter()
.find(|added| added.task_id == action.task_id)
.map(|added| added.exchange_id)
.ok_or(UpdateConversationError::TaskNotFound)?;
let message_id = MessageId::new(action.id.to_string());
let exchange = self.get_exchange_to_update(exchange_id)?;
match &exchange.output_status {
AIAgentOutputStatus::Streaming {
output: Some(output),
} => output
.get_mut()
.messages
.push(AIAgentOutputMessage::action(message_id, action)),
AIAgentOutputStatus::Streaming { output: None } => {
return Err(UpdateConversationError::OutputNeverInitialized);
}
AIAgentOutputStatus::Finished { .. } => {
return Err(UpdateConversationError::OutputAlreadyFinished);
}
}
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
pub fn update_cost_and_usage_for_request(
&mut self,
request_cost: Option<RequestCost>,
+2 -2
View File
@@ -11,7 +11,7 @@ use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream;
use crate::ai::agent::api::LegacyResponseStream;
use crate::settings::ai::BedrockAuthMethod;
fn strip_context_marker(model_id: &str) -> String {
@@ -185,7 +185,7 @@ impl BedrockClient {
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
tool_result_archive: Vec<ConversationMessage>,
) -> Result<ResponseStream, BedrockError> {
) -> Result<LegacyResponseStream, BedrockError> {
let base_model_id = strip_context_marker(model_id);
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(&base_model_id, &self.region)
+2 -2
View File
@@ -15,7 +15,7 @@ use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::diagnostic::BedrockDiagnosticLogger;
use crate::ai::agent::api::Event;
use crate::ai::agent::api::LegacyEvent;
use crate::server::server_api::AIApiError;
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
@@ -69,7 +69,7 @@ pub fn bedrock_stream_to_response_events(
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String,
tool_result_archive: Vec<ConversationMessage>,
) -> BoxStream<'static, Event> {
) -> BoxStream<'static, LegacyEvent> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
+2 -2
View File
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use crate::ai::agent::api::ResponseStream;
use crate::ai::agent::api::LegacyResponseStream;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig, BedrockError};
use crate::ai::bedrock::convert::ConversationMessage;
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
@@ -25,7 +25,7 @@ pub struct TranslatorRequest {
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<ResponseStream, BedrockError> {
) -> Result<LegacyResponseStream, BedrockError> {
let config = params.config.with_external_fallbacks();
let cross_region_inference = config.cross_region_inference;
let bedrock = BedrockClient::from_config(config).await?;
+7 -1
View File
@@ -267,9 +267,15 @@ fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bo
} else {
ToolResultStatus::Success
};
let content = action_result.result.model_content();
let content = if permission_denied {
format!("Permission denied by the user. {content}")
} else {
content
};
ToolResult {
call_id: action_result.id.to_string(),
content: action_result.result.model_content(),
content,
status,
}
}
@@ -139,6 +139,8 @@ fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
assert_eq!(failure.call_id, "failure");
assert_eq!(cancelled.call_id, "cancelled");
assert_eq!(denied.call_id, "cancelled");
assert!(!cancelled.content.contains("Permission denied"));
assert!(denied.content.contains("Permission denied by the user"));
}
#[test]
+17 -1
View File
@@ -3423,7 +3423,23 @@ impl BlocklistAIController {
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
match event {
Ok(event) => {
Ok(api::StreamEvent::ToolProposed(action)) => {
let apply_result = history_model.update(ctx, |history_model, ctx| {
history_model.apply_domain_tool_proposal(
&stream_id,
conversation_id,
self.terminal_surface_id,
action,
ctx,
)
});
if let Err(error) = apply_result {
log::error!(
"Failed to apply Rig tool proposal to conversation: {error:?}"
);
}
}
Ok(api::StreamEvent::Response(event)) => {
// If this controller is part of a shared session, forward the entire response event to viewers first.
if FeatureFlag::AgentSharedSessions.is_enabled()
&& !response_stream.as_ref(ctx).is_acp()
@@ -186,7 +186,7 @@ impl ResponseStream {
ctx: &mut ModelContext<Self>,
) {
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Ok(
event,
api::StreamEvent::Response(event),
))));
}
#[cfg(test)]
@@ -718,7 +718,16 @@ impl ResponseStream {
self.time_to_latest_event = Local::now().signed_duration_since(self.start_time);
match &event {
Ok(response_event) => {
Ok(api::StreamEvent::ToolProposed(action)) => {
self.has_received_client_actions = true;
log::debug!(
"Rig proposed domain tool action {} for task {}",
action.id,
action.task_id
);
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
}
Ok(api::StreamEvent::Response(response_event)) => {
let event_type_name = match &response_event.r#type {
Some(warp_multi_agent_api::response_event::Type::Init(_)) => "Init",
Some(warp_multi_agent_api::response_event::Type::ClientActions(a)) => {
+18 -3
View File
@@ -33,9 +33,9 @@ use crate::ai::agent::conversation::{
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus,
CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost,
Suggestions,
AIAgentAction, AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError,
RequestCost, Suggestions,
};
use crate::ai::artifacts::Artifact;
use crate::ai::document::ai_document_model::AIDocumentModel;
@@ -1852,6 +1852,21 @@ impl BlocklistAIHistoryModel {
Ok(())
}
pub fn apply_domain_tool_proposal(
&mut self,
response_stream_id: &ResponseStreamId,
conversation_id: AIConversationId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
self.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?
.apply_domain_tool_proposal(response_stream_id, terminal_surface_id, action, ctx)?;
Ok(())
}
pub fn update_conversation_cost_and_usage_for_request(
&mut self,
conversation_id: AIConversationId,
@@ -486,7 +486,7 @@ async fn extract_suggestion_from_stream(
let mut client_actions: Vec<api::ClientAction> = Vec::new();
let mut server_request_token: Option<String> = None;
while let Some(event) = stream.next().await {
let Ok(response_event) = event else {
let Ok(crate::ai::agent::api::StreamEvent::Response(response_event)) = event else {
continue;
};
match response_event.r#type {
+2 -2
View File
@@ -9,7 +9,7 @@ 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::api::Event;
use crate::ai::agent::api::LegacyEvent;
use crate::ai::bedrock::response_translator::{
build_create_task, build_stream_init, context_window_for_model,
};
@@ -47,7 +47,7 @@ pub(crate) struct StreamUsage {
pub fn openai_stream_to_response_events(
byte_stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
context: OpenAIStreamContext,
) -> BoxStream<'static, Event> {
) -> BoxStream<'static, LegacyEvent> {
use futures::StreamExt;
let OpenAIStreamContext {
+2 -2
View File
@@ -6,7 +6,7 @@ use super::client::{OpenAIClient, OpenAIClientConfig, OpenAIError};
use super::convert::build_openai_request;
use super::request_translator::sanitize_messages_for_openai;
use super::response_translator::{openai_stream_to_response_events, OpenAIStreamContext};
use crate::ai::agent::api::ResponseStream;
use crate::ai::agent::api::LegacyResponseStream;
use crate::ai::bedrock::request_translator;
use crate::ai::provider::types::{ConversationMessage, MessageContent, MessageRole};
@@ -108,7 +108,7 @@ pub(crate) fn prepare_turn(params: &TranslatorRequest, request: &mut api::Reques
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<ResponseStream, OpenAIError> {
) -> Result<LegacyResponseStream, OpenAIError> {
let client = OpenAIClient::from_config(params.config.clone());
let PreparedTurn {
task_id,
+1
View File
@@ -1,6 +1,7 @@
mod provider;
mod rig;
mod rig_request;
mod rig_tool;
pub(crate) use provider::ProviderRuntime;
pub(crate) use rig::rig_openai_response_stream;
+32 -19
View File
@@ -12,7 +12,9 @@ use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent, ToolType};
use super::rig_request::{prepare_rig_turn, PreparedRigTurn};
use crate::ai::agent::api::{Event, RequestParams, ResponseStream};
use super::rig_tool::action_from_tool_call;
use crate::ai::agent::api::{Event, RequestParams, ResponseStream, StreamEvent};
use crate::ai::agent::AIAgentAction;
use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
@@ -29,6 +31,7 @@ pub(crate) fn rig_openai_response_stream(
supported_cli_agent_tools: Vec<ToolType>,
cancellation_rx: oneshot::Receiver<()>,
) -> ResponseStream {
let skill_path_origin = params.session_context.skill_path_origin();
let PreparedRigTurn {
task_id,
needs_create_task,
@@ -111,30 +114,30 @@ pub(crate) fn rig_openai_response_stream(
match event {
AgentEvent::TurnStarted { .. } => {
initialized = true;
yield Ok(build_stream_init(&request_id, &conversation_id));
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
if needs_create_task {
yield Ok(build_create_task(&task_id));
yield Ok(StreamEvent::Response(build_create_task(&task_id)));
}
if let Some(user_query) = &user_query {
yield Ok(build_user_query_message(&task_id, user_query));
yield Ok(StreamEvent::Response(build_user_query_message(&task_id, user_query)));
}
}
AgentEvent::TextDelta { text } => {
full_text.push_str(&text);
if let Some(message_id) = &current_text_message_id {
yield Ok(build_append_text(&task_id, message_id, &text));
yield Ok(StreamEvent::Response(build_append_text(&task_id, message_id, &text)));
} else {
let message_id = Uuid::new_v4().to_string();
yield Ok(build_add_agent_output_message(&task_id, &message_id, &text));
yield Ok(StreamEvent::Response(build_add_agent_output_message(&task_id, &message_id, &text)));
current_text_message_id = Some(message_id);
}
}
AgentEvent::ReasoningDelta { text } => {
if let Some(message_id) = &current_reasoning_message_id {
yield Ok(build_append_reasoning(&task_id, message_id, &text));
yield Ok(StreamEvent::Response(build_append_reasoning(&task_id, message_id, &text)));
} else {
let message_id = Uuid::new_v4().to_string();
yield Ok(build_add_reasoning(&task_id, &message_id, &text));
yield Ok(StreamEvent::Response(build_add_reasoning(&task_id, &message_id, &text)));
current_reasoning_message_id = Some(message_id);
}
}
@@ -155,7 +158,16 @@ pub(crate) fn rig_openai_response_stream(
.unwrap_or_default();
match tool_policy.decide(&call, &history, &tool_result_archive) {
ToolCallDecision::Execute => {
yield Ok(build_tool_proposed(&task_id, &call));
match build_tool_proposed(&task_id, &call, &skill_path_origin) {
Ok(action) => yield Ok(StreamEvent::ToolProposed(action)),
Err(message) => {
yield Err(agent_error(AgentError::new(
galaxy_agent_core::AgentErrorKind::Protocol,
message,
)));
return;
}
}
}
ToolCallDecision::Inline(result) => {
append_tool_result(&messages_sent, result);
@@ -172,17 +184,17 @@ pub(crate) fn rig_openai_response_stream(
);
append_tool_result(&messages_sent, result);
let message_id = Uuid::new_v4().to_string();
yield Ok(build_add_agent_output_message(
yield Ok(StreamEvent::Response(build_add_agent_output_message(
&task_id,
&message_id,
&error_display,
));
)));
}
}
}
AgentEvent::TurnStopped { reason } => {
if !initialized {
yield Ok(build_stream_init(&request_id, &conversation_id));
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
}
sync_assistant_turn(
&messages_sent,
@@ -190,7 +202,7 @@ pub(crate) fn rig_openai_response_stream(
&proposed_tools,
&mut assistant_history_index,
);
yield Ok(build_stream_finished(
yield Ok(StreamEvent::Response(build_stream_finished(
map_stop_reason(reason),
StreamUsage {
input_tokens: saturating_i32(usage.input_tokens),
@@ -203,7 +215,7 @@ pub(crate) fn rig_openai_response_stream(
model_id,
max_context_tokens,
},
));
)));
return;
}
AgentEvent::Tool { .. } => {
@@ -304,11 +316,12 @@ fn sync_assistant_turn(
sent.push(message);
}
fn build_tool_proposed(task_id: &str, call: &ToolCall) -> ResponseEvent {
let arguments = serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_string());
crate::ai::bedrock::response_translator::build_tool_call_message(
task_id, &call.id, &call.name, &arguments,
)
fn build_tool_proposed(
task_id: &str,
call: &ToolCall,
skill_path_origin: &ai::skills::SkillPathOrigin,
) -> Result<AIAgentAction, String> {
action_from_tool_call(task_id, call, skill_path_origin)
}
fn build_add_reasoning(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
+1 -1
View File
@@ -395,7 +395,7 @@ fn tool_definitions(
.collect::<HashSet<_>>();
for server in &mcp_context.servers {
for tool in &server.tools {
let name = format!("mcp__{}__{}", server.name, tool.name);
let name = format!("mcp__{}__{}", server.id, tool.name);
if seen.insert(name.clone()) {
tools.push(ToolDefinition {
name,
+38 -2
View File
@@ -4,9 +4,11 @@ use std::sync::Arc;
use galaxy_agent_core::{ContentPart, MessageContent, MessageRole, ToolResult, ToolResultStatus};
use warp_multi_agent_api::ToolType;
use super::{input_messages, prepare_rig_turn};
use super::{input_messages, prepare_rig_turn, tool_definitions};
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentContext, AIAgentInput, AnyFileContent, FileContext, UserQueryMode};
use crate::ai::agent::{
AIAgentContext, AIAgentInput, AnyFileContent, FileContext, MCPContext, MCPServer, UserQueryMode,
};
use crate::ai::llms::LLMId;
use crate::ai::openai::client::OpenAIClientConfig;
@@ -112,6 +114,40 @@ fn builds_a_rig_turn_directly_from_galaxy_request_state() {
));
}
#[test]
#[allow(deprecated)]
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
let tool = serde_json::from_value(serde_json::json!({
"name": "echo",
"description": "Echo input",
"inputSchema": {
"type": "object",
"properties": {"message": {"type": "string"}}
}
}))
.unwrap();
let context = MCPContext {
resources: Vec::new(),
tools: Vec::new(),
servers: vec![MCPServer {
id: "11111111-1111-4111-8111-111111111111".to_string(),
name: "Friendly Server".to_string(),
description: String::new(),
resources: Vec::new(),
tools: vec![tool],
}],
};
let tools = tool_definitions(&[ToolType::CallMcpTool], Some(&context));
assert!(tools
.iter()
.any(|tool| { tool.name == "mcp__11111111-1111-4111-8111-111111111111__echo" }));
assert!(!tools
.iter()
.any(|tool| tool.name == "mcp__Friendly Server__echo"));
}
#[test]
fn normalized_tool_outcomes_are_the_only_action_results_sent_to_rig() {
let statuses = [
+30 -53
View File
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use ai::skills::SkillPathOrigin;
use galaxy_agent_core::{
MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
};
@@ -66,8 +67,8 @@ fn reasoning_events_match_the_existing_ui_message_contract() {
}
#[test]
fn tool_proposal_matches_the_existing_permission_ui_contract() {
let event = build_tool_proposed(
fn tool_proposal_matches_the_domain_permission_contract() {
let action = build_tool_proposed(
"task",
&ToolCall {
id: "call-1".to_string(),
@@ -77,67 +78,43 @@ fn tool_proposal_matches_the_existing_permission_ui_contract() {
"is_read_only": true
}),
},
);
&SkillPathOrigin::Local,
)
.unwrap();
let Some(warp_multi_agent_api::response_event::Type::ClientActions(actions)) = event.r#type
else {
panic!("expected client actions");
};
let Some(warp_multi_agent_api::client_action::Action::AddMessagesToTask(add)) =
&actions.actions[0].action
else {
panic!("expected add-message action");
};
let Some(warp_multi_agent_api::message::Message::ToolCall(tool_call)) =
&add.messages[0].message
else {
panic!("expected tool-call message");
};
let Some(warp_multi_agent_api::message::tool_call::Tool::RunShellCommand(command)) =
&tool_call.tool
else {
panic!("expected run-shell-command payload");
};
assert_eq!(tool_call.tool_call_id, "call-1");
assert_eq!(command.command, "cargo test");
assert!(command.is_read_only);
assert_eq!(action.id.to_string(), "call-1");
assert!(matches!(
action.action,
crate::ai::agent::AIAgentActionType::RequestCommandOutput {
command,
is_read_only: Some(true),
..
} if command == "cargo test"
));
}
#[test]
fn mcp_tool_proposal_routes_through_the_existing_mcp_executor_contract() {
let event = build_tool_proposed(
fn mcp_tool_proposal_routes_directly_to_the_mcp_executor_contract() {
let action = build_tool_proposed(
"task",
&ToolCall {
id: "call-mcp".to_string(),
name: "mcp__filesystem__read_file".to_string(),
name: "mcp__11111111-1111-4111-8111-111111111111__read_file".to_string(),
arguments: serde_json::json!({"path": "Cargo.toml"}),
},
);
&SkillPathOrigin::Local,
)
.unwrap();
let Some(warp_multi_agent_api::response_event::Type::ClientActions(actions)) = event.r#type
else {
panic!("expected client actions");
};
let Some(warp_multi_agent_api::client_action::Action::AddMessagesToTask(add)) =
&actions.actions[0].action
else {
panic!("expected add-message action");
};
let Some(warp_multi_agent_api::message::Message::ToolCall(tool_call)) =
&add.messages[0].message
else {
panic!("expected tool-call message");
};
let Some(warp_multi_agent_api::message::tool_call::Tool::CallMcpTool(call)) = &tool_call.tool
else {
panic!("expected MCP tool payload");
};
assert_eq!(tool_call.tool_call_id, "call-mcp");
assert_eq!(call.server_id, "filesystem");
assert_eq!(call.name, "read_file");
assert!(call.args.is_some());
assert!(matches!(
action.action,
crate::ai::agent::AIAgentActionType::CallMCPTool {
server_id: Some(server_id),
name,
..
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
&& name == "read_file"
));
}
#[test]
+319
View File
@@ -0,0 +1,319 @@
use std::time::Duration;
use ai::diff_validation::ParsedDiff;
use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::ToolCall;
use uuid::Uuid;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentPtyWriteMode, AskUserQuestionItem,
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
ReadFilesRequest, ReadSkillRequest, SearchCodebaseRequest, ShellCommandDelay,
StartAgentExecutionMode, StartAgentVersion,
};
use crate::ai::document::ai_document_model::AIDocumentId;
pub(super) fn action_from_tool_call(
task_id: &str,
call: &ToolCall,
skill_path_origin: &SkillPathOrigin,
) -> Result<AIAgentAction, String> {
let input = &call.arguments;
let action = match call.name.as_str() {
"run_shell_command" => AIAgentActionType::RequestCommandOutput {
command: string(input, "command"),
is_read_only: Some(boolean(input, "is_read_only")),
is_risky: Some(boolean(input, "is_risky")),
wait_until_completion: boolean(input, "wait_until_complete"),
uses_pager: Some(boolean(input, "uses_pager")),
rationale: None,
citations: Vec::new(),
},
"read_files" => AIAgentActionType::ReadFiles(ReadFilesRequest {
locations: input
.get("files")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(file_location)
.collect(),
}),
"apply_file_diffs" => AIAgentActionType::RequestFileEdits {
file_edits: file_edits(input),
title: nonempty_string(input, "summary"),
},
"grep" => AIAgentActionType::Grep {
queries: strings(input, "queries"),
path: string(input, "path"),
},
"file_glob" => AIAgentActionType::FileGlob {
patterns: strings(input, "patterns"),
path: nonempty_string(input, "path"),
},
"search_codebase" => AIAgentActionType::SearchCodebase(SearchCodebaseRequest {
query: string(input, "query"),
partial_paths: nonempty_strings(input, "path_filters"),
codebase_path: nonempty_string(input, "path"),
}),
"write_to_long_running_shell_command" => {
AIAgentActionType::WriteToLongRunningShellCommand {
block_id: string(input, "command_id").into(),
input: string(input, "input").into_bytes().into(),
mode: match input.get("mode").and_then(serde_json::Value::as_str) {
Some("line") => AIAgentPtyWriteMode::Line,
Some("block") => AIAgentPtyWriteMode::Block,
Some("raw") | Some(_) | None => AIAgentPtyWriteMode::Raw,
},
}
}
"interrupt_shell_command" => AIAgentActionType::WriteToLongRunningShellCommand {
block_id: string(input, "command_id").into(),
input: vec![galaxy_terminal::model::escape_sequences::C0::ETX].into(),
mode: AIAgentPtyWriteMode::Raw,
},
"read_shell_command_output" => AIAgentActionType::ReadShellCommandOutput {
block_id: string(input, "command_id").into(),
delay: Some(ShellCommandDelay::Duration(Duration::from_secs(
input
.get("wait_seconds")
.and_then(serde_json::Value::as_u64)
.unwrap_or(2)
.min(crate::ai::bedrock::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS),
))),
},
"read_mcp_resource" => AIAgentActionType::ReadMCPResource {
server_id: uuid(input, "server_id"),
name: String::new(),
uri: nonempty_string(input, "uri"),
},
"read_plan" | "read_documents" | "read_notebook" => {
AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
document_ids: strings(input, "document_ids")
.into_iter()
.filter_map(|id| AIDocumentId::try_from(id).ok())
.collect(),
})
}
"create_plan" | "create_documents" | "create_notebook" => {
AIAgentActionType::CreateDocuments(CreateDocumentsRequest {
documents: input
.get("documents")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|document| {
Some(DocumentToCreate {
title: document.get("title")?.as_str()?.to_string(),
content: document.get("content")?.as_str()?.to_string(),
})
})
.collect(),
})
}
"edit_plan" | "edit_documents" | "edit_notebook" => {
AIAgentActionType::EditDocuments(EditDocumentsRequest {
diffs: input
.get("diffs")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|diff| {
Some(DocumentDiff {
document_id: AIDocumentId::try_from(diff.get("document_id")?.as_str()?)
.ok()?,
search: string(diff, "search"),
replace: string(diff, "replace"),
})
})
.collect(),
})
}
"start_agent" => AIAgentActionType::StartAgent {
version: StartAgentVersion::V1,
name: string(input, "name"),
prompt: string(input, "prompt"),
execution_mode: StartAgentExecutionMode::local_with_defaults(),
lifecycle_subscription: None,
},
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
addresses: vec![string(input, "agent_id")],
subject: String::new(),
message: string(input, "message"),
},
"ask_user_question" => AIAgentActionType::AskUserQuestion {
questions: vec![AskUserQuestionItem {
question_id: Uuid::new_v4().to_string(),
question: string(input, "question"),
question_type: AskUserQuestionType::MultipleChoice {
is_multiselect: false,
options: strings(input, "options")
.into_iter()
.enumerate()
.map(|(index, label)| AskUserQuestionOption {
label,
recommended: index == 0,
})
.collect(),
supports_other: true,
},
}],
},
"read_skill" => {
let skill = string(input, "skill");
let skill = match input
.get("reference_type")
.and_then(serde_json::Value::as_str)
{
Some("bundled") => SkillReference::BundledSkillId(skill),
Some("path") | Some(_) | None => SkillReference::Path(
skill_path_origin
.location_for_path(skill)
.map_err(|error| error.to_string())?,
),
};
AIAgentActionType::ReadSkill(ReadSkillRequest { skill })
}
"fetch_conversation" => AIAgentActionType::FetchConversation {
conversation_id: string(input, "conversation_id"),
},
name if name.starts_with("mcp__") => {
let mut parts = name.splitn(3, "__");
let _prefix = parts.next();
let server_id = parts.next().and_then(|value| Uuid::parse_str(value).ok());
let name = parts
.next()
.unwrap_or_else(|| name.strip_prefix("mcp__").unwrap_or(name))
.to_string();
AIAgentActionType::CallMCPTool {
server_id,
name,
input: input.clone(),
}
}
name => return Err(format!("unsupported Rig tool proposal: {name}")),
};
let tool_name = matches!(
call.name.as_str(),
"read_notebook" | "create_notebook" | "edit_notebook"
)
.then(|| "notebook".to_string());
Ok(AIAgentAction {
id: call.id.clone().into(),
task_id: TaskId::new(task_id.to_string()),
action,
requires_result: true,
tool_name,
})
}
fn string(input: &serde_json::Value, key: &str) -> String {
input
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}
fn nonempty_string(input: &serde_json::Value, key: &str) -> Option<String> {
let value = string(input, key);
(!value.is_empty()).then_some(value)
}
fn boolean(input: &serde_json::Value, key: &str) -> bool {
input
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn strings(input: &serde_json::Value, key: &str) -> Vec<String> {
input
.get(key)
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect()
}
fn nonempty_strings(input: &serde_json::Value, key: &str) -> Option<Vec<String>> {
let values = strings(input, key);
(!values.is_empty()).then_some(values)
}
fn uuid(input: &serde_json::Value, key: &str) -> Option<Uuid> {
input
.get(key)
.and_then(serde_json::Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok())
}
fn file_location(file: &serde_json::Value) -> Option<FileLocations> {
if let Some(name) = file.as_str() {
return Some(FileLocations {
name: name.to_string(),
lines: Vec::new(),
});
}
let name = file
.get("path")
.or_else(|| file.get("name"))?
.as_str()?
.to_string();
let lines = file
.get("line_ranges")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|range| {
let start = usize::try_from(range.get("start")?.as_u64()?).ok()?;
let end = usize::try_from(range.get("end")?.as_u64()?).ok()?;
(start > 0 && end >= start).then_some(start..end)
})
.collect();
Some(FileLocations { name, lines })
}
fn file_edits(input: &serde_json::Value) -> Vec<FileEdit> {
let diffs = input
.get("diffs")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|diff| {
FileEdit::Edit(ParsedDiff::StrReplaceEdit {
file: nonempty_string(diff, "file_path"),
search: nonempty_string(diff, "search"),
replace: nonempty_string(diff, "replace"),
})
});
let creates = input
.get("new_files")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|file| FileEdit::Create {
file: nonempty_string(file, "file_path"),
content: nonempty_string(file, "content"),
});
let deletes = input
.get("deleted_files")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|file| FileEdit::Delete {
file: file
.as_str()
.map(ToOwned::to_owned)
.or_else(|| nonempty_string(file, "file_path")),
});
diffs.chain(creates).chain(deletes).collect()
}
#[cfg(test)]
#[path = "rig_tool_tests.rs"]
mod tests;
+140
View File
@@ -0,0 +1,140 @@
use std::path::PathBuf;
use ai::diff_validation::ParsedDiff;
use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::ToolCall;
use super::action_from_tool_call;
use crate::ai::agent::{AIAgentActionType, FileEdit};
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
ToolCall {
id: "call-1".to_string(),
name: name.to_string(),
arguments,
}
}
#[test]
fn shell_calls_become_domain_actions_without_a_proto_round_trip() {
let action = action_from_tool_call(
"task-1",
&call(
"run_shell_command",
serde_json::json!({
"command": "cargo test",
"is_read_only": true,
"is_risky": false
}),
),
&SkillPathOrigin::Local,
)
.unwrap();
assert_eq!(action.id.to_string(), "call-1");
assert_eq!(action.task_id.to_string(), "task-1");
assert!(matches!(
action.action,
AIAgentActionType::RequestCommandOutput {
command,
is_read_only: Some(true),
is_risky: Some(false),
..
} if command == "cargo test"
));
}
#[test]
fn edit_calls_preserve_file_edits_in_the_domain_model() {
let action = action_from_tool_call(
"task-1",
&call(
"apply_file_diffs",
serde_json::json!({
"summary": "Update greeting",
"diffs": [{
"file_path": "/tmp/greeting.txt",
"search": "hello",
"replace": "hello galaxy"
}]
}),
),
&SkillPathOrigin::Local,
)
.unwrap();
let AIAgentActionType::RequestFileEdits { file_edits, title } = action.action else {
panic!("expected file-edit action");
};
assert_eq!(title.as_deref(), Some("Update greeting"));
assert!(matches!(
&file_edits[0],
FileEdit::Edit(ParsedDiff::StrReplaceEdit {
file: Some(file),
search: Some(search),
replace: Some(replace),
}) if file == "/tmp/greeting.txt" && search == "hello" && replace == "hello galaxy"
));
}
#[test]
fn grouped_mcp_calls_keep_the_installation_uuid_and_json_input() {
let action = action_from_tool_call(
"task-1",
&call(
"mcp__11111111-1111-4111-8111-111111111111__echo",
serde_json::json!({"message": "hello"}),
),
&SkillPathOrigin::Local,
)
.unwrap();
assert!(matches!(
action.action,
AIAgentActionType::CallMCPTool {
server_id: Some(server_id),
name,
input,
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
&& name == "echo"
&& input == serde_json::json!({"message": "hello"})
));
}
#[test]
fn local_skill_paths_preserve_the_session_origin() {
let action = action_from_tool_call(
"task-1",
&call(
"read_skill",
serde_json::json!({
"skill": "/tmp/example/SKILL.md",
"reference_type": "path"
}),
),
&SkillPathOrigin::Local,
)
.unwrap();
assert!(matches!(
action.action,
AIAgentActionType::ReadSkill(request)
if request.skill == SkillReference::Path(
galaxy_util::local_or_remote_path::LocalOrRemotePath::Local(PathBuf::from(
"/tmp/example/SKILL.md"
))
)
));
}
#[test]
fn unknown_tools_are_rejected_before_the_permission_boundary() {
let error = action_from_tool_call(
"task-1",
&call("invented_tool", serde_json::json!({})),
&SkillPathOrigin::Local,
)
.unwrap_err();
assert!(error.contains("unsupported Rig tool proposal"));
}
@@ -612,6 +612,30 @@ pub fn assert_task_is_blocked(conversation_target: ConversationTarget) -> Assert
})
}
/// Asserts that the conversation was explicitly cancelled by the user.
pub fn assert_task_is_cancelled(conversation_target: ConversationTarget) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
let conversation =
match get_conversation(conversation_target, terminal_view.id(), history_model) {
Ok(conversation) => conversation,
Err(assertion) => return assertion,
};
match conversation.status() {
ConversationStatus::Cancelled => AssertionOutcome::Success,
status if status.is_in_progress() => {
AssertionOutcome::failure("Task is still in progress".to_owned())
}
status => AssertionOutcome::immediate_failure(format!(
"Expected task to be cancelled, but status is {status:?}"
)),
}
})
})
}
/// Check if a conversation has ended with an API error in its latest exchange.
fn check_for_api_error_in_latest_exchange(
conversation: &AIConversation,
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fs::read;
use std::io::Cursor;
use std::path::Path;
@@ -10,6 +11,10 @@ use prost::Message;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::ActionPermission;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::mcp::{
JsonTemplate, TemplatableMCPServer, TemplatableMCPServerInstallation,
TemplatableMCPServerManager,
};
use crate::integration_testing::agent_mode::{
assert_latest_task_succeeds_or_blocked, assert_task_is_blocked, ConversationTarget,
};
@@ -260,3 +265,119 @@ pub fn set_execution_profile_no_auto_execute() -> TestStep {
},
)
}
/// Sets the execution profile to auto-execute commands.
pub fn set_execution_profile_auto_execute() -> TestStep {
TestStep::new("Set execution profile to auto-execute commands").add_named_assertion(
"Update execution profile",
|app, _window_id| {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let default_profile_id = *profiles.default_profile(ctx).id();
profiles.set_execute_commands(
default_profile_id,
&ActionPermission::AlwaysAllow,
ctx,
);
});
async_assert!(true, "Successfully updated execution profile")
},
)
}
/// Sets the execution profile to auto-apply code diffs.
pub fn set_execution_profile_auto_apply_code_diffs() -> TestStep {
TestStep::new("Set execution profile to auto-apply code diffs").add_named_assertion(
"Update execution profile",
|app, _window_id| {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let default_profile_id = *profiles.default_profile(ctx).id();
profiles.set_apply_code_diffs(
default_profile_id,
&ActionPermission::AlwaysAllow,
ctx,
);
});
async_assert!(true, "Successfully updated execution profile")
},
)
}
/// Sets the execution profile to auto-execute MCP tools.
pub fn set_execution_profile_auto_execute_mcp_tools() -> TestStep {
TestStep::new("Set execution profile to auto-execute MCP tools").add_named_assertion(
"Update execution profile",
|app, _window_id| {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let default_profile_id = *profiles.default_profile(ctx).id();
profiles.set_mcp_permissions(
default_profile_id,
&ActionPermission::AlwaysAllow,
ctx,
);
});
async_assert!(true, "Successfully updated execution profile")
},
)
}
/// Starts an ephemeral MCP stdio server for an integration test.
pub fn start_ephemeral_mcp_server_for_testing(
command: String,
argument: String,
installation_id: &str,
template_id: &str,
server_name: &str,
) -> TestStep {
let installation_id =
uuid::Uuid::parse_str(installation_id).expect("valid MCP installation UUID");
let template_id = uuid::Uuid::parse_str(template_id).expect("valid MCP template UUID");
let server_name = server_name.to_string();
new_step_with_default_assertions("Start ephemeral integration MCP server").add_named_assertion(
"Spawn MCP server",
move |app, _window_id| {
let template_json = serde_json::json!({
(&server_name): {
"command": command,
"args": [argument],
},
})
.to_string();
let server = TemplatableMCPServer {
uuid: template_id,
name: server_name.clone(),
description: Some("Hermetic MCP server for integration testing".to_string()),
template: JsonTemplate {
json: template_json,
variables: Vec::new(),
},
version: 1,
gallery_data: None,
};
let installation =
TemplatableMCPServerInstallation::new(installation_id, server, HashMap::new());
TemplatableMCPServerManager::handle(app).update(app, |manager, ctx| {
manager.spawn_cli_ephemeral_server(installation, ctx);
});
async_assert!(true, "Ephemeral MCP server spawn requested")
},
)
}
/// Waits for an ephemeral MCP server to complete its protocol handshake.
pub fn wait_until_mcp_server_is_active_for_testing(
installation_id: &str,
timeout: Duration,
) -> TestStep {
let installation_id =
uuid::Uuid::parse_str(installation_id).expect("valid MCP installation UUID");
new_step_with_default_assertions("Wait for ephemeral integration MCP server")
.set_timeout(timeout)
.add_named_assertion("MCP server is active", move |app, _window_id| {
let is_active = TemplatableMCPServerManager::handle(app)
.read(app, |manager, _| manager.is_server_active(installation_id));
async_assert!(
is_active,
"Waiting for ephemeral MCP server to become active"
)
})
}
+10
View File
@@ -24,6 +24,10 @@ pub struct Args {
}
pub fn main() -> Result<()> {
if env::args().nth(1).as_deref() == Some(integration::rig_test_mcp_server::SERVER_ARGUMENT) {
return integration::rig_test_mcp_server::run();
}
ChannelState::set(ChannelState::new(
Channel::Integration,
ChannelConfig {
@@ -429,6 +433,12 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> {
register_test!(test_agent_mode_pane_minimum_size);
register_test!(test_rig_read_tool_round_trip);
register_test!(test_rig_shell_tool_success_round_trip);
register_test!(test_rig_shell_tool_failure_round_trip);
register_test!(test_rig_shell_tool_permission_denial);
register_test!(test_rig_edit_tool_round_trip);
register_test!(test_rig_in_flight_cancellation);
register_test!(test_rig_mcp_tool_round_trip);
register_test!(test_git_prompt_chips);
// These tests are only invoked manually, and not included in the
+1
View File
@@ -1,4 +1,5 @@
mod builder;
pub mod rig_test_mcp_server;
mod step;
pub mod test;
@@ -0,0 +1,89 @@
use std::io::{self, BufRead as _, Write as _};
use anyhow::Context as _;
pub const SERVER_ARGUMENT: &str = "--rig-test-mcp-server";
pub const TOOL_RESULT_PREFIX: &str = "rig-mcp-echo: ";
pub fn run() -> anyhow::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line.context("failed to read MCP request")?;
let request: serde_json::Value =
serde_json::from_str(&line).context("failed to parse MCP request")?;
let Some(id) = request.get("id").cloned() else {
continue;
};
let method = request
.get("method")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let result = match method {
"initialize" => {
let protocol_version = request
.pointer("/params/protocolVersion")
.cloned()
.unwrap_or_else(|| serde_json::json!("2024-11-05"));
serde_json::json!({
"protocolVersion": protocol_version,
"capabilities": {"tools": {}},
"serverInfo": {
"name": "rig-integration-mcp",
"version": "1.0.0",
},
})
}
"tools/list" => serde_json::json!({
"tools": [{
"name": "echo",
"description": "Echo text through the hermetic Rig integration MCP server",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
}],
}),
"tools/call" => {
let text = request
.pointer("/params/arguments/text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
serde_json::json!({
"content": [{
"type": "text",
"text": format!("{TOOL_RESULT_PREFIX}{text}"),
}],
"isError": false,
})
}
"ping" => serde_json::json!({}),
_ => {
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("Unsupported test MCP method: {method}"),
},
});
writeln!(stdout, "{response}").context("failed to write MCP error response")?;
stdout
.flush()
.context("failed to flush MCP error response")?;
continue;
}
};
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
});
writeln!(stdout, "{response}").context("failed to write MCP response")?;
stdout.flush().context("failed to flush MCP response")?;
}
Ok(())
}
+525 -65
View File
@@ -1,48 +1,316 @@
use std::io::{ErrorKind, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use galaxyui_core::async_assert;
use warp::features::FeatureFlag;
use warp::integration_testing::agent_mode::{
assert_latest_exchange_text, enter_agent_view, set_preferred_agent_mode_llm,
submit_ai_query_and_wait_until_done,
assert_latest_exchange_text, assert_task_is_cancelled, enter_agent_view,
set_execution_profile_auto_apply_code_diffs, set_execution_profile_auto_execute,
set_execution_profile_auto_execute_mcp_tools, set_execution_profile_no_auto_execute,
set_preferred_agent_mode_llm, start_ephemeral_mcp_server_for_testing, submit_ai_query,
submit_ai_query_and_wait_until_blocked, submit_ai_query_and_wait_until_done,
wait_until_mcp_server_is_active_for_testing, ConversationTarget,
};
use warp::integration_testing::step::new_step_with_default_assertions;
use warp::integration_testing::terminal::wait_until_bootstrapped_single_pane_for_tab;
use warp::integration_testing::terminal::{
assert_input_is_focused, wait_until_bootstrapped_single_pane_for_tab,
};
use super::new_builder;
use crate::rig_test_mcp_server::{SERVER_ARGUMENT as MCP_SERVER_ARGUMENT, TOOL_RESULT_PREFIX};
use crate::Builder;
const MODEL_ID: &str = "integration-rig-model";
const FINAL_TEXT: &str = "Rig read round trip completed.";
const READ_CALL_ID: &str = "rig-read-call";
const READ_FINAL_TEXT: &str = "Rig read round trip completed.";
const FIXTURE_CONTENT: &str = "content returned through the Galaxy read executor";
const SHELL_CALL_ID: &str = "rig-shell-call";
const SHELL_SUCCESS_OUTPUT: &str = "rig-shell-success-output";
const SHELL_SUCCESS_FINAL_TEXT: &str = "Rig shell success round trip completed.";
const SHELL_FAILURE_OUTPUT: &str = "rig-shell-failure-output";
const SHELL_FAILURE_FINAL_TEXT: &str = "Rig shell failure round trip completed.";
const SHELL_DENIED_FINAL_TEXT: &str = "Rig shell denial was preserved.";
const EDIT_CALL_ID: &str = "rig-edit-call";
const EDIT_INITIAL_CONTENT: &str = "before Rig edit\n";
const EDIT_UPDATED_CONTENT: &str = "after Rig edit\n";
const EDIT_FINAL_TEXT: &str = "Rig edit round trip completed.";
const MCP_CALL_ID: &str = "rig-mcp-call";
const MCP_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
const MCP_TEMPLATE_ID: &str = "22222222-2222-4222-8222-222222222222";
const MCP_SERVER_NAME: &str = "rig-integration";
const MCP_TOOL_NAME: &str = "mcp__11111111-1111-4111-8111-111111111111__echo";
const MCP_INPUT: &str = "hello from Rig";
const MCP_FINAL_TEXT: &str = "Rig MCP round trip completed.";
#[derive(Clone)]
enum MockScenario {
Read {
fixture_path: Arc<Mutex<String>>,
},
ShellSuccess,
ShellFailure,
ShellDenied {
marker_path: Arc<Mutex<String>>,
},
Edit {
fixture_path: Arc<Mutex<String>>,
},
Cancellation {
stream_started: Arc<AtomicBool>,
stream_cancelled: Arc<AtomicBool>,
},
Mcp,
}
pub fn test_rig_read_tool_round_trip() -> Builder {
FeatureFlag::AgentView.set_enabled(true);
let fixture_path = Arc::new(Mutex::new(String::new()));
let stop = Arc::new(AtomicBool::new(false));
let (address, server_thread) = start_mock_provider(fixture_path.clone(), stop.clone());
let server_thread = Arc::new(Mutex::new(Some(server_thread)));
rig_builder(MockScenario::Read { fixture_path })
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Read the integration fixture and report when the read is complete.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig read result reached Agent Mode")
.add_named_assertion(
"Final response follows the real read tool result",
assert_latest_exchange_text(|text| text.contains(READ_FINAL_TEXT)),
),
)
}
let setup_fixture_path = fixture_path.clone();
pub fn test_rig_shell_tool_success_round_trip() -> Builder {
rig_builder(MockScenario::ShellSuccess)
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(set_execution_profile_auto_execute())
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Run the requested shell success check.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig shell success reached Agent Mode")
.add_named_assertion(
"Final response follows the successful shell result",
assert_latest_exchange_text(|text| text.contains(SHELL_SUCCESS_FINAL_TEXT)),
),
)
}
pub fn test_rig_shell_tool_failure_round_trip() -> Builder {
rig_builder(MockScenario::ShellFailure)
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(set_execution_profile_auto_execute())
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Run the requested failing shell check.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig shell failure reached Agent Mode")
.add_named_assertion(
"Final response follows the failed shell result",
assert_latest_exchange_text(|text| text.contains(SHELL_FAILURE_FINAL_TEXT)),
),
)
}
pub fn test_rig_shell_tool_permission_denial() -> Builder {
let marker_path = Arc::new(Mutex::new(String::new()));
rig_builder(MockScenario::ShellDenied { marker_path })
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(set_execution_profile_no_auto_execute())
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_blocked(
"Propose the requested shell command and wait for my decision.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Deny the blocked Rig shell command")
.with_keystrokes(&["ctrl-c"])
.add_named_assertion(
"Input returns after denying the command",
assert_input_is_focused(),
),
)
.with_step(submit_ai_query_and_wait_until_done(
"Continue without running the denied command.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig denial reached Agent Mode")
.add_named_assertion(
"Final response follows the explicit denied result",
assert_latest_exchange_text(|text| text.contains(SHELL_DENIED_FINAL_TEXT)),
),
)
}
pub fn test_rig_edit_tool_round_trip() -> Builder {
let fixture_path = Arc::new(Mutex::new(String::new()));
rig_builder(MockScenario::Edit { fixture_path })
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(set_execution_profile_auto_apply_code_diffs())
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Apply the requested edit to the integration fixture.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig edit result reached Agent Mode")
.add_named_assertion(
"Final response follows the real edit result",
assert_latest_exchange_text(|text| text.contains(EDIT_FINAL_TEXT)),
),
)
}
pub fn test_rig_in_flight_cancellation() -> Builder {
let stream_started = Arc::new(AtomicBool::new(false));
let stream_cancelled = Arc::new(AtomicBool::new(false));
rig_builder(MockScenario::Cancellation {
stream_started: stream_started.clone(),
stream_cancelled: stream_cancelled.clone(),
})
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(enter_agent_view())
.with_step(submit_ai_query(
"Start the cancellable Rig response.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Wait for the Rig provider stream to start")
.set_timeout(Duration::from_secs(30))
.add_named_assertion("Provider stream started", move |_app, _window_id| {
async_assert!(
stream_started.load(Ordering::SeqCst),
"Waiting for the provider stream to start"
)
}),
)
.with_step(
new_step_with_default_assertions("Cancel the in-flight Rig provider stream")
.set_timeout(Duration::from_secs(30))
.with_keystrokes(&["ctrl-c"])
.add_named_assertion(
"Conversation is marked cancelled",
assert_task_is_cancelled(ConversationTarget::Active),
)
.add_named_assertion(
"Provider connection was dropped",
move |_app, _window_id| {
async_assert!(
stream_cancelled.load(Ordering::SeqCst),
"Waiting for cancellation to close the provider connection"
)
},
)
.add_named_assertion(
"Input returns after cancelling the stream",
assert_input_is_focused(),
),
)
}
pub fn test_rig_mcp_tool_round_trip() -> Builder {
let command = std::env::current_exe()
.expect("integration executable path")
.to_string_lossy()
.into_owned();
rig_builder(MockScenario::Mcp)
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(start_ephemeral_mcp_server_for_testing(
command,
MCP_SERVER_ARGUMENT.to_string(),
MCP_INSTALLATION_ID,
MCP_TEMPLATE_ID,
MCP_SERVER_NAME,
))
.with_step(wait_until_mcp_server_is_active_for_testing(
MCP_INSTALLATION_ID,
Duration::from_secs(30),
))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(set_execution_profile_auto_execute_mcp_tools())
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Call the hermetic MCP echo tool.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig MCP result reached Agent Mode")
.add_named_assertion(
"Final response follows the real MCP result",
assert_latest_exchange_text(|text| text.contains(MCP_FINAL_TEXT)),
),
)
}
fn rig_builder(scenario: MockScenario) -> Builder {
FeatureFlag::AgentView.set_enabled(true);
FeatureFlag::MCPGroupedServerContext.set_enabled(true);
let stop = Arc::new(AtomicBool::new(false));
let setup_scenario = scenario.clone();
let (address, server_thread) = start_mock_provider(scenario, stop.clone());
let server_thread = Arc::new(Mutex::new(Some(server_thread)));
let cleanup_stop = stop.clone();
let cleanup_thread = server_thread.clone();
new_builder()
.with_setup(move |utils| {
let fixture = utils.test_dir().join("rig-read-fixture.txt");
std::fs::write(&fixture, FIXTURE_CONTENT)
.expect("should write Rig integration fixture");
*setup_fixture_path.lock().expect("fixture path lock") =
fixture.to_string_lossy().into_owned();
write_provider_settings(address);
match &setup_scenario {
MockScenario::Read { fixture_path } => {
let fixture = utils.test_dir().join("rig-read-fixture.txt");
std::fs::write(&fixture, FIXTURE_CONTENT)
.expect("should write Rig integration fixture");
*fixture_path.lock().expect("fixture path lock") =
fixture.to_string_lossy().into_owned();
}
MockScenario::ShellDenied { marker_path } => {
let marker = utils.test_dir().join("rig-denied-command-marker.txt");
*marker_path.lock().expect("marker path lock") =
marker.to_string_lossy().into_owned();
}
MockScenario::Edit { fixture_path } => {
let fixture = utils.test_dir().join("rig-edit-fixture.txt");
std::fs::write(&fixture, EDIT_INITIAL_CONTENT)
.expect("should write Rig edit integration fixture");
*fixture_path.lock().expect("fixture path lock") =
fixture.to_string_lossy().into_owned();
}
MockScenario::ShellSuccess
| MockScenario::ShellFailure
| MockScenario::Cancellation { .. }
| MockScenario::Mcp => {}
}
})
.with_cleanup(move |_utils| {
cleanup_stop.store(true, Ordering::SeqCst);
if let Some(handle) = cleanup_thread.lock().expect("server thread lock").take() {
handle.join().expect("mock provider should stop cleanly");
}
})
}
let settings_path = warp::settings::user_preferences_toml_file_path();
std::fs::create_dir_all(settings_path.parent().expect("settings parent"))
.expect("should create settings directory");
let settings = format!(
r#"[ai.openai]
fn write_provider_settings(address: SocketAddr) {
let settings_path = warp::settings::user_preferences_toml_file_path();
std::fs::create_dir_all(settings_path.parent().expect("settings parent"))
.expect("should create settings directory");
let settings = format!(
r#"[ai.openai]
enabled = true
[[ai.providers]]
@@ -56,33 +324,12 @@ context_size = 128000
use_rig = true
supports_system_messages = false
"#
);
std::fs::write(settings_path, settings).expect("should write provider settings");
})
.with_cleanup(move |_utils| {
cleanup_stop.store(true, Ordering::SeqCst);
if let Some(handle) = cleanup_thread.lock().expect("server thread lock").take() {
handle.join().expect("mock provider should stop cleanly");
}
})
.with_step(wait_until_bootstrapped_single_pane_for_tab(0))
.with_step(set_preferred_agent_mode_llm(MODEL_ID))
.with_step(enter_agent_view())
.with_step(submit_ai_query_and_wait_until_done(
"Read the integration fixture and report when the read is complete.",
Duration::from_secs(60),
))
.with_step(
new_step_with_default_assertions("Assert Rig read result reached Agent Mode")
.add_named_assertion(
"Final response follows the real read tool result",
assert_latest_exchange_text(|text| text.contains(FINAL_TEXT)),
),
)
);
std::fs::write(settings_path, settings).expect("should write provider settings");
}
fn start_mock_provider(
fixture_path: Arc<Mutex<String>>,
scenario: MockScenario,
stop: Arc<AtomicBool>,
) -> (SocketAddr, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("should bind mock Rig provider");
@@ -95,7 +342,7 @@ fn start_mock_provider(
while !stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((mut stream, _)) => {
serve_request(&mut stream, &fixture_path, &request_count);
serve_request(&mut stream, &scenario, &request_count, &stop);
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
@@ -109,8 +356,9 @@ fn start_mock_provider(
fn serve_request(
stream: &mut TcpStream,
fixture_path: &Mutex<String>,
scenario: &MockScenario,
request_count: &AtomicUsize,
stop: &AtomicBool,
) {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
@@ -130,21 +378,20 @@ fn serve_request(
"unexpected mock provider request: {request_line}"
);
let turn = request_count.fetch_add(1, Ordering::SeqCst);
if let MockScenario::Cancellation {
stream_started,
stream_cancelled,
} = scenario
{
assert_eq!(turn, 0, "unexpected extra cancellation chat request");
write_cancellable_response(stream, stream_started, stream_cancelled, stop);
return;
}
let body = match turn {
0 => {
let fixture = fixture_path.lock().expect("fixture path lock").clone();
tool_call_sse(&fixture)
}
0 => tool_call_sse(scenario),
1 => {
assert!(
request.contains("rig-read-call"),
"follow-up request should preserve the tool call ID"
);
assert!(
request.contains(FIXTURE_CONTENT),
"follow-up request should contain the real file contents returned by Galaxy"
);
final_text_sse()
assert_follow_up_request(scenario, &request);
final_text_sse(final_text(scenario))
}
_ => panic!("unexpected extra chat completion request"),
};
@@ -186,8 +433,60 @@ fn read_request(stream: &mut TcpStream) -> String {
String::from_utf8(request).expect("provider request should be valid UTF-8")
}
fn tool_call_sse(fixture_path: &str) -> String {
let arguments = serde_json::json!({"files": [fixture_path]}).to_string();
fn tool_call_sse(scenario: &MockScenario) -> String {
let (call_id, tool_name, arguments) = match scenario {
MockScenario::Read { fixture_path } => {
let fixture_path = fixture_path.lock().expect("fixture path lock").clone();
(
READ_CALL_ID,
"read_files",
serde_json::json!({"files": [fixture_path]}),
)
}
MockScenario::ShellSuccess => (
SHELL_CALL_ID,
"run_shell_command",
shell_arguments("printf '%s\\n' 'rig-shell-success-output'"),
),
MockScenario::ShellFailure => (
SHELL_CALL_ID,
"run_shell_command",
shell_arguments("(printf '%s\\n' 'rig-shell-failure-output' >&2; exit 7)"),
),
MockScenario::ShellDenied { marker_path } => {
let marker_path = marker_path.lock().expect("marker path lock").clone();
(
SHELL_CALL_ID,
"run_shell_command",
shell_arguments(&format!(
"printf '%s\\n' 'command-must-not-run' > '{marker_path}'"
)),
)
}
MockScenario::Edit { fixture_path } => {
let fixture_path = fixture_path.lock().expect("fixture path lock").clone();
(
EDIT_CALL_ID,
"apply_file_diffs",
serde_json::json!({
"summary": "Update the Rig edit integration fixture",
"diffs": [{
"file_path": fixture_path,
"search": EDIT_INITIAL_CONTENT,
"replace": EDIT_UPDATED_CONTENT,
}],
}),
)
}
MockScenario::Cancellation { .. } => {
unreachable!("cancellation streams do not issue tool calls")
}
MockScenario::Mcp => (
MCP_CALL_ID,
MCP_TOOL_NAME,
serde_json::json!({"text": MCP_INPUT}),
),
};
let tool_delta = serde_json::json!({
"id": "rig-integration-1",
"model": MODEL_ID,
@@ -195,11 +494,11 @@ fn tool_call_sse(fixture_path: &str) -> String {
"delta": {
"tool_calls": [{
"index": 0,
"id": "rig-read-call",
"id": call_id,
"type": "function",
"function": {
"name": "read_files",
"arguments": arguments,
"name": tool_name,
"arguments": arguments.to_string(),
},
}],
},
@@ -220,12 +519,125 @@ fn tool_call_sse(fixture_path: &str) -> String {
format!("data: {tool_delta}\n\ndata: {tool_stop}\n\ndata: {usage}\n\ndata: [DONE]\n\n")
}
fn final_text_sse() -> String {
fn shell_arguments(command: &str) -> serde_json::Value {
serde_json::json!({
"command": command,
"is_read_only": false,
"is_risky": false,
"uses_pager": false,
"wait_until_complete": true,
})
}
fn assert_follow_up_request(scenario: &MockScenario, request: &str) {
match scenario {
MockScenario::Read { .. } => {
assert!(
request.contains(READ_CALL_ID),
"follow-up request should preserve the read tool call ID"
);
assert!(
request.contains(FIXTURE_CONTENT),
"follow-up request should contain the real file contents returned by Galaxy"
);
}
MockScenario::ShellSuccess => {
assert!(
request.contains(SHELL_CALL_ID),
"follow-up request should preserve the shell tool call ID"
);
assert!(
request.contains(SHELL_SUCCESS_OUTPUT),
"follow-up request should contain successful shell output"
);
assert!(
request.contains("exit code 0"),
"follow-up request should contain the successful exit code"
);
}
MockScenario::ShellFailure => {
assert!(
request.contains(SHELL_CALL_ID),
"follow-up request should preserve the failed shell tool call ID"
);
assert!(
request.contains(SHELL_FAILURE_OUTPUT),
"follow-up request should contain failed shell output"
);
assert!(
request.contains("exit code 7"),
"follow-up request should contain the failed exit code"
);
assert!(
request.contains("[ERROR]"),
"failed shell result should remain an explicit model error"
);
}
MockScenario::ShellDenied { marker_path } => {
assert!(
request.contains(SHELL_CALL_ID),
"follow-up request should preserve the denied shell tool call ID"
);
assert!(
request.contains("[ERROR] Permission denied by the user"),
"denied shell result should remain an explicit model error"
);
let marker_path = marker_path.lock().expect("marker path lock");
assert!(
!Path::new(marker_path.as_str()).exists(),
"denied shell command must not create its marker file"
);
}
MockScenario::Edit { fixture_path } => {
assert!(
request.contains(EDIT_CALL_ID),
"follow-up request should preserve the edit tool call ID"
);
assert!(
request.contains(EDIT_UPDATED_CONTENT.trim()),
"follow-up request should contain the updated file content"
);
let fixture_path = fixture_path.lock().expect("fixture path lock");
let actual = std::fs::read_to_string(fixture_path.as_str())
.expect("edited fixture should remain readable");
assert_eq!(actual, EDIT_UPDATED_CONTENT);
}
MockScenario::Cancellation { .. } => {
unreachable!("cancellation streams do not issue follow-up requests")
}
MockScenario::Mcp => {
assert!(
request.contains(MCP_CALL_ID),
"follow-up request should preserve the MCP tool call ID"
);
assert!(
request.contains(&format!("{TOOL_RESULT_PREFIX}{MCP_INPUT}")),
"follow-up request should contain the real MCP tool result"
);
}
}
}
fn final_text(scenario: &MockScenario) -> &'static str {
match scenario {
MockScenario::Read { .. } => READ_FINAL_TEXT,
MockScenario::ShellSuccess => SHELL_SUCCESS_FINAL_TEXT,
MockScenario::ShellFailure => SHELL_FAILURE_FINAL_TEXT,
MockScenario::ShellDenied { .. } => SHELL_DENIED_FINAL_TEXT,
MockScenario::Edit { .. } => EDIT_FINAL_TEXT,
MockScenario::Cancellation { .. } => {
unreachable!("cancellation streams do not produce final text")
}
MockScenario::Mcp => MCP_FINAL_TEXT,
}
}
fn final_text_sse(final_text: &str) -> String {
let text = serde_json::json!({
"id": "rig-integration-2",
"model": MODEL_ID,
"choices": [{
"delta": {"content": FINAL_TEXT, "tool_calls": []},
"delta": {"content": final_text, "tool_calls": []},
"finish_reason": "stop",
}],
"usage": null,
@@ -246,3 +658,51 @@ fn write_response(stream: &mut TcpStream, content_type: &str, body: &str) {
.expect("should write mock provider response");
stream.flush().expect("should flush mock provider response");
}
fn write_cancellable_response(
stream: &mut TcpStream,
stream_started: &AtomicBool,
stream_cancelled: &AtomicBool,
stop: &AtomicBool,
) {
let text = serde_json::json!({
"id": "rig-cancellation-1",
"model": MODEL_ID,
"choices": [{
"delta": {"content": "Rig cancellation stream is active.", "tool_calls": []},
"finish_reason": null,
}],
"usage": null,
});
let initial_body = format!("data: {text}\n\n");
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n{initial_body}"
)
.expect("should start cancellable provider response");
stream
.flush()
.expect("should flush cancellable provider response");
stream_started.store(true, Ordering::SeqCst);
while !stop.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(20));
if let Err(error) = stream
.write_all(b": keepalive\n\n")
.and_then(|()| stream.flush())
{
assert!(
matches!(
error.kind(),
ErrorKind::BrokenPipe
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::NotConnected
),
"unexpected cancellable stream error: {error}"
);
stream_cancelled.store(true, Ordering::SeqCst);
return;
}
}
}
@@ -312,6 +312,12 @@ integration_tests! {
test_middle_click_paste,
test_agent_mode_pane_minimum_size,
test_rig_read_tool_round_trip,
test_rig_shell_tool_success_round_trip,
test_rig_shell_tool_failure_round_trip,
test_rig_shell_tool_permission_denial,
test_rig_edit_tool_round_trip,
test_rig_in_flight_cancellation,
test_rig_mcp_tool_round_trip,
test_rule_creation,
test_rule_update,
+13 -7
View File
@@ -315,18 +315,25 @@ Exit condition: a LiteLLM or local OpenAI-compatible conversation streams throug
protobuf round trip.
- [x] Separate concise UI result summaries from authoritative model-facing result content so file,
code-search, document, skill, and shell results retain their payload without protobuf conversion.
- [ ] Move permission decisions and tool start/result events fully onto the Galaxy domain contract,
- [x] Move permission decisions and tool start/result events fully onto the Galaxy domain contract,
removing the temporary Warp protobuf adapter.
- [ ] Add end-to-end integration coverage for representative read, edit, shell, MCP, denial,
- [x] Add end-to-end integration coverage for representative read, edit, shell, MCP, denial,
cancellation, and execution-failure flows.
- [x] Add a hermetic real-app Rig read-tool round trip covering isolated provider configuration,
streamed tool proposal, Galaxy-owned execution, normalized tool result, and model follow-up.
- [x] Add hermetic real-app shell coverage for successful execution, nonzero exit with preserved
stderr/error status, and an `AlwaysAsk` user denial that proves the command never executes.
- [x] Port loop prevention, inline `recall_tool_history`, and unknown-tool handling to domain-level
policies.
Exit condition: representative read, edit, shell, MCP, denial, and failure flows pass integration
tests without provider-specific UI code.
Phase 3 is complete. Rig tool proposals now enter the controller as typed `AIAgentAction` values;
Galaxy's action model owns permission and execution lifecycle events; normalized results return to
Rig directly. The legacy Warp response envelope remains only around transcript/init/finished UI
rendering and non-Rig compatibility runtimes, not in Rig's executable tool path.
### Phase 4 — Bedrock through Rig
- Implement Bedrock client construction and model resolution through `rig-bedrock`.
@@ -411,8 +418,7 @@ contract is what the UI and persistence observe.
## Immediate next vertical slice
Expand the hermetic Rig integration harness from its passing read-tool round trip to
edit/shell/MCP/denial/cancellation/execution-failure cases. Then move permission decisions and tool
start/result events fully onto the Galaxy domain contract, keeping the outgoing UI response adapter
only until those flows prove that Galaxy-owned events can replace it without changing the
permission UI.
Begin Phase 4 with a focused `rig-bedrock` compatibility spike. Establish client construction and
model/inference-profile resolution first, then add semantic parity fixtures for system prompts,
images, tool schemas, cache controls, reasoning, usage, and context limits before routing any
configured Bedrock model away from the existing compatibility implementation.