first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+208 -62
View File
@@ -4,12 +4,28 @@
//! If some UI state is stored in the client, it needs to also be represented in the proto tasks somehow so it can be restored.
//! Some conversions may be lossy if it's not important to recover that UI state.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use ai::agent::action_result::{
AskUserQuestionAnswerItem, AskUserQuestionResult, FetchConversationResult, ReadSkillResult,
RequestComputerUseResult, SendMessageToAgentResult, StartAgentResult, StartAgentVersion,
UseComputerResult,
};
use ai::skills::{ParsedSkill, SkillPathOrigin};
use chrono::{DateTime, Local, TimeZone};
use persistence::model::AgentConversationData;
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use warp_multi_agent_api::ask_user_question_result::answer_item::Answer as AskUserQuestionAnswer;
use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
};
use crate::ai::agent::conversation::update_todo_list_from_todo_op;
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::{
@@ -24,7 +40,7 @@ use crate::ai::agent::{
RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId,
Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult,
SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext,
UploadArtifactResult, WriteToLongRunningShellCommandResult,
UploadArtifactResult, UserQueryMode, WriteToLongRunningShellCommandResult,
};
use crate::ai::block_context::BlockContext;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -32,22 +48,6 @@ use crate::ai::llms::LLMId;
use crate::ai_assistant::execution_context::{WarpAiExecutionContext, WarpAiOsContext};
use crate::terminal::model::block::BlockId;
use crate::terminal::model::terminal_model::BlockIndex;
use ai::agent::action_result::{
AskUserQuestionAnswerItem, AskUserQuestionResult, FetchConversationResult, ReadSkillResult,
RequestComputerUseResult, SendMessageToAgentResult, StartAgentResult, StartAgentVersion,
UseComputerResult,
};
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeZone};
use galaxy_core::command::ExitCode;
use persistence::model::AgentConversationData;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use warp_multi_agent_api as api;
use warp_multi_agent_api::ask_user_question_result::answer_item::Answer as AskUserQuestionAnswer;
use crate::ai::agent::conversation::ServerAIConversationMetadata;
use crate::ai::agent::UserQueryMode;
/// How to restore a conversation from the cloud.
pub enum RestorationMode {
@@ -81,12 +81,14 @@ pub fn convert_conversation_data_to_ai_conversation(
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
pinned: false,
},
RestorationMode::Continue => AgentConversationData {
server_conversation_token: Some(
@@ -98,16 +100,16 @@ pub fn convert_conversation_data_to_ai_conversation(
artifacts_json: serde_json::to_string(&metadata.artifacts).ok(),
parent_agent_id: None,
agent_name: None,
orchestration_harness_type: None,
parent_conversation_id: None,
// TODO: Populate run_id from server metadata once it is exposed
// in ServerAIConversationMetadata. For cloud conversations that
// were spawned via the server API, the run_id is created at task
// dispatch time; adding it here would avoid a round-trip to StreamInit.
run_id: None,
is_remote_child: false,
root_task_is_optimistic: None,
run_id: metadata
.ambient_agent_task_id
.map(|task_id| task_id.to_string()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
pinned: false,
},
};
@@ -239,7 +241,8 @@ pub(crate) fn convert_input_context(context: Option<&api::InputContext>) -> Arc<
};
// Convert binary data to base64
use base64::{engine::general_purpose, Engine};
use base64::engine::general_purpose;
use base64::Engine;
let data = general_purpose::STANDARD.encode(&image.data);
result.push(AIAgentContext::Image(ImageContext {
@@ -436,7 +439,10 @@ impl ConvertToExchanges for &api::Task {
api::message::system_query::Type::ResumeConversation(_)
| api::message::system_query::Type::GeneratePassiveSuggestions(_)
// TODO: Implement this for real. ZB adding this to bump proto version for unrelated API changes.
| api::message::system_query::Type::SummarizeConversation(_)=> false,
| api::message::system_query::Type::SummarizeConversation(_)
// HandoffRehydration is injected by the server for agent-only
// context; the client must never render it as user input.
| api::message::system_query::Type::HandoffRehydration(_) => false,
}
}
api::message::Message::ToolCallResult(tool_call_result) => {
@@ -462,7 +468,10 @@ impl ConvertToExchanges for &api::Task {
}
api::message::Message::InvokeSkill(invoke_skill) => {
if let Some(api_skill) = invoke_skill.skill.clone() {
if let Ok(parsed_skill) = ParsedSkill::try_from(api_skill) {
if let Ok(parsed_skill) = ParsedSkill::try_from_api_with_origin(
api_skill,
&SkillPathOrigin::RestoredDisplayOnly,
) {
let user_query = invoke_skill
.user_query
.clone()
@@ -513,7 +522,8 @@ impl ConvertToExchanges for &api::Task {
| api::message::Message::DebugOutput(_)
| api::message::Message::ArtifactEvent(_)
| api::message::Message::MessagesReceivedFromAgents(_)
| api::message::Message::ModelUsed(_) => false,
| api::message::Message::ModelUsed(_)
| api::message::Message::OrchestrationConfigSnapshot(_) => false,
};
if !added_message_as_exchange_input {
@@ -524,6 +534,7 @@ impl ConvertToExchanges for &api::Task {
// TODO(alokedesai): Support persistence for the code review state.
active_code_review: None,
task_id: &TaskId::new(api_message.task_id.clone()),
skill_path_origin: &SkillPathOrigin::Unavailable,
})
{
current_outputs.push(output_msg);
@@ -581,6 +592,14 @@ pub(crate) fn convert_tool_call_result_to_input(
command: result.command.clone(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished
.start_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished
.finish_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}
}
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(
@@ -627,6 +646,8 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished.start_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished.finish_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
},
Some(api::write_to_long_running_shell_command_result::Result::Error(api::ShellCommandError{
r#type: Some(api::shell_command_error::Type::CommandNotFound(()))
@@ -1217,6 +1238,14 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished
.start_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished
.finish_ts
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}
}
Some(
@@ -1267,6 +1296,8 @@ pub(crate) fn convert_tool_call_result_to_input(
block_id: finished.command_id.clone().into(),
output: finished.output.clone(),
exit_code: ExitCode::from(finished.exit_code),
start_ts: finished.start_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
completed_ts: finished.finish_ts.as_ref().map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
},
Some(api::transfer_shell_command_control_to_user_result::Result::Error(
api::ShellCommandError {
@@ -1315,34 +1346,52 @@ pub(crate) fn convert_tool_call_result_to_input(
})
}
Some(ToolCallResultType::UseComputer(result)) => {
let use_computer_result = match &result.result {
Some(api::use_computer_result::Result::Success(success)) => {
let screenshot = success.screenshot.as_ref().map(|s| {
// The original dimensions are not preserved through the API, so we use
// the current dimensions for both.
computer_use::Screenshot {
width: s.width as usize,
height: s.height as usize,
original_width: s.width as usize,
original_height: s.height as usize,
data: s.data.clone(),
mime_type: s.mime_type.clone().into(),
}
});
let cursor_position = success
.cursor_position
.as_ref()
.map(|c| computer_use::Vector2I::new(c.x, c.y));
UseComputerResult::Success(computer_use::ActionResult {
screenshot,
cursor_position,
})
}
Some(api::use_computer_result::Result::Error(error)) => {
UseComputerResult::Error(error.message.clone())
}
None => UseComputerResult::Cancelled,
};
let use_computer_result =
match &result.result {
Some(api::use_computer_result::Result::Success(success)) => {
let screenshot = success.screenshot.as_ref().map(|s| {
// The original dimensions are not preserved through the API, so we use
// the current dimensions for both.
computer_use::Screenshot {
width: s.width as usize,
height: s.height as usize,
original_width: s.width as usize,
original_height: s.height as usize,
data: s.data.clone(),
mime_type: s.mime_type.clone().into(),
}
});
let cursor_position = success
.cursor_position
.as_ref()
.map(|c| computer_use::Vector2I::new(c.x, c.y));
let windows = success
.windows
.iter()
.map(convert_api_window_info)
.collect();
// A present captured-window message indicates a window screenshot was taken.
// The window id is an opaque string on the wire; on macOS it is a CGWindowID,
// so parse it back to a u32, defaulting to 0 when it is not parseable.
let captured_window = success.captured_window.as_ref().map(|c| {
computer_use::CapturedWindow {
window_id: c.window_id.parse().unwrap_or(0),
width_px: c.width_px,
height_px: c.height_px,
}
});
UseComputerResult::Success(computer_use::ActionResult {
screenshot,
cursor_position,
windows,
captured_window,
})
}
Some(api::use_computer_result::Result::Error(error)) => {
UseComputerResult::Error(error.message.clone())
}
None => UseComputerResult::Cancelled,
};
Some(AIAgentInput::ActionResult {
result: AIAgentActionResult {
@@ -1361,6 +1410,7 @@ pub(crate) fn convert_tool_call_result_to_input(
api::request_computer_use_result::Approved {
screen_dimensions: Some(screen_dimensions),
initial_screenshot: Some(initial_screenshot),
windows,
..
},
Some(platform),
@@ -1374,6 +1424,7 @@ pub(crate) fn convert_tool_call_result_to_input(
mime_type: initial_screenshot.mime_type.clone().into(),
},
platform,
windows: windows.iter().map(convert_api_window_info).collect(),
},
_ => RequestComputerUseResult::Error(
"Missing screen dimensions, initial screenshot, or valid platform"
@@ -1544,6 +1595,76 @@ pub(crate) fn convert_tool_call_result_to_input(
context,
})
}
Some(ToolCallResultType::RunAgentsResult(result)) => {
use ai::agent::action_result::{
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
RunAgentsResult,
};
let run_agents_result = match &result.outcome {
Some(api::run_agents_result::Outcome::Launched(launched)) => {
let execution_mode = match &launched.resolved_execution_mode {
Some(api::run_agents_result::launched::ResolvedExecutionMode::Remote(
remote,
)) => RunAgentsLaunchedExecutionMode::Remote {
environment_id: remote.environment_id.clone(),
worker_host: remote.worker_host.clone(),
computer_use_enabled: remote.computer_use_enabled,
},
Some(api::run_agents_result::launched::ResolvedExecutionMode::Local(_))
| None => RunAgentsLaunchedExecutionMode::Local,
};
let agents = launched
.agents
.iter()
.map(|outcome| RunAgentsAgentOutcome {
name: outcome.name.clone(),
kind: match &outcome.result {
Some(api::run_agents_result::agent_outcome::Result::Launched(
launched_agent,
)) => RunAgentsAgentOutcomeKind::Launched {
agent_id: launched_agent.agent_id.clone(),
},
Some(api::run_agents_result::agent_outcome::Result::Failed(
failed,
)) => RunAgentsAgentOutcomeKind::Failed {
error: failed.error.clone(),
},
None => RunAgentsAgentOutcomeKind::Failed {
error: String::new(),
},
},
})
.collect();
RunAgentsResult::Launched {
model_id: launched.resolved_model_id.clone(),
harness_type:
crate::ai::agent::api::convert_from::convert_run_agents_harness(
launched.resolved_harness.as_ref(),
)
.unwrap_or_default(),
execution_mode,
agents,
}
}
Some(api::run_agents_result::Outcome::Denied(denied)) => RunAgentsResult::Denied {
reason: denied.reason.clone(),
},
Some(api::run_agents_result::Outcome::Failure(failure)) => {
RunAgentsResult::Failure {
error: failure.error.clone(),
}
}
None => RunAgentsResult::Cancelled,
};
Some(AIAgentInput::ActionResult {
result: AIAgentActionResult {
id: tool_call_id.into(),
task_id: task_id.clone(),
result: AIAgentActionResultType::RunAgents(run_agents_result),
},
context,
})
}
// Deprecated/unused result types or absent result.
Some(ToolCallResultType::SuggestCreatePlan(..))
| Some(ToolCallResultType::SuggestPlan(..))
@@ -1551,6 +1672,7 @@ pub(crate) fn convert_tool_call_result_to_input(
log::warn!("No result present for tool call ID: {tool_call_id}");
None
}
Some(ToolCallResultType::WaitForEvents(_)) => None,
}
}
@@ -1678,8 +1800,14 @@ fn create_cancelled_result_for_tool_call(
ToolType::SendMessageToAgent(_) => {
AIAgentActionResultType::SendMessageToAgent(SendMessageToAgentResult::Cancelled)
}
ToolType::RunAgents(_) => {
AIAgentActionResultType::RunAgents(ai::agent::action_result::RunAgentsResult::Cancelled)
}
// These tools are deprecated.
ToolType::SuggestCreatePlan(_) | ToolType::SuggestPlan(_) => return None,
ToolType::WaitForEvents(_) => {
return None;
}
};
Some(AIAgentInput::ActionResult {
@@ -1777,6 +1905,10 @@ fn create_exchange_from_messages(
model_id: model.model_id.clone().into(),
display_name: model.model_display_name.clone(),
is_fallback: model.is_fallback,
prompt_cache_expires_at: model
.prompt_cache_expires_at
.as_ref()
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
}),
request_cost: None,
};
@@ -1879,7 +2011,8 @@ where
| api::message::Message::DebugOutput(_)
| api::message::Message::ArtifactEvent(_)
| api::message::Message::InvokeSkill(_)
| api::message::Message::ModelUsed(_) => {
| api::message::Message::ModelUsed(_)
| api::message::Message::OrchestrationConfigSnapshot(_) => {
message.timestamp.as_ref().map(|timestamp| {
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
})
@@ -1985,7 +2118,7 @@ fn convert_passive_suggestion_result_to_input(
context,
})
}
fn proto_timestamp_to_local_datetime(seconds: i64, nanos: i32) -> DateTime<Local> {
pub(crate) fn proto_timestamp_to_local_datetime(seconds: i64, nanos: i32) -> DateTime<Local> {
let nanos = if nanos < 0 { 0 } else { nanos as u32 };
Local
@@ -2014,6 +2147,19 @@ fn convert_api_platform(platform: i32) -> Option<computer_use::Platform> {
}
}
/// Reconstructs the internal computer_use window record from the API `WindowInfo` message.
fn convert_api_window_info(window: &api::WindowInfo) -> computer_use::WindowInfo {
computer_use::WindowInfo {
// The window id arrives as an opaque string; on macOS it is a CGWindowID (u32). Default to
// 0 when it is not parseable.
window_id: window.window_id.parse().unwrap_or(0),
pid: window.pid,
app_name: window.app_name.clone(),
title: window.title.clone(),
layer: window.layer,
}
}
#[cfg(test)]
#[path = "convert_conversation_tests.rs"]
mod tests;
@@ -1,7 +1,56 @@
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::{AIAgentInput, UserQueryMode};
use std::collections::HashMap;
use chrono::Utc;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
};
use crate::ai::agent::{AIAgentInput, UserQueryMode};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::cloud_object::{Revision, ServerMetadata, ServerPermissions};
use crate::persistence::model::ConversationUsageMetadata;
use crate::server::ids::ServerId;
fn test_server_metadata(
server_token: &str,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
) -> ServerAIConversationMetadata {
ServerAIConversationMetadata {
title: "test conversation".to_string(),
working_directory: None,
harness: AIAgentHarness::Oz,
usage: ConversationUsageMetadata {
was_summarized: false,
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
context_window_segments: Vec::new(),
},
metadata: ServerMetadata {
uid: ServerId::default(),
revision: Revision::now(),
metadata_last_updated_ts: Utc::now().into(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
current_editor_uid: None,
},
permissions: ServerPermissions::mock_personal(),
creator: None,
ambient_agent_task_id,
server_conversation_token: ServerConversationToken::new(server_token.to_string()),
artifacts: vec![],
}
}
fn test_skill() -> api::Skill {
api::Skill {
descriptor: Some(api::SkillDescriptor {
@@ -25,6 +74,40 @@ fn test_skill() -> api::Skill {
}
}
#[test]
#[allow(deprecated)]
fn test_convert_conversation_data_to_ai_conversation_sets_restored_run_id() {
let conversation_id = AIConversationId::new();
let ambient_agent_task_id: AmbientAgentTaskId =
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
let conversation_data = api::ConversationData {
tasks: vec![api::Task {
id: "root".to_string(),
messages: vec![],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
}],
ordered_message_ids: vec![],
};
let conversation = convert_conversation_data_to_ai_conversation(
conversation_id,
&conversation_data,
test_server_metadata("server-token", Some(ambient_agent_task_id)),
RestorationMode::Continue,
)
.expect("conversation should restore");
assert_eq!(conversation.id(), conversation_id);
assert_eq!(conversation.task_id(), Some(ambient_agent_task_id));
assert_eq!(
conversation.run_id(),
Some(ambient_agent_task_id.to_string())
);
}
#[test]
fn test_convert_tool_call_result_to_input_transfer_control_snapshot() {
let task_id = crate::ai::agent::task::TaskId::new("task".to_string());
@@ -277,6 +360,7 @@ fn test_into_exchanges_basic() {
// Create minimal test data
let messages = vec![
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -292,6 +376,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -305,6 +390,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "user_msg2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -320,6 +406,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -333,6 +420,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "user_msg3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -348,6 +436,7 @@ fn test_into_exchanges_basic() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -392,6 +481,7 @@ fn test_invoke_skill_arguments_round_trip() {
let query = "arg1 arg2".to_string();
let messages = vec![
api::Message {
fetched_memories: vec![],
id: "invoke_skill_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -412,6 +502,7 @@ fn test_invoke_skill_arguments_round_trip() {
timestamp: None,
},
api::Message {
fetched_memories: vec![],
id: "agent_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -448,7 +539,7 @@ fn test_invoke_skill_arguments_round_trip() {
Some("arg1 arg2")
);
assert_eq!(
exchanges[0].input[0].user_query().as_deref(),
exchanges[0].input[0].display_query().as_deref(),
Some("/test-skill arg1 arg2")
);
}
@@ -459,6 +550,7 @@ fn test_invoke_skill_arguments_round_trip() {
#[test]
fn test_invoke_skill_missing_user_query_maps_to_none() {
let messages = vec![api::Message {
fetched_memories: vec![],
id: "invoke_skill_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -492,7 +584,7 @@ fn test_invoke_skill_missing_user_query_maps_to_none() {
assert_eq!(skill.name, "test-skill");
assert_eq!(user_query, &None);
assert_eq!(
exchanges[0].input[0].user_query().as_deref(),
exchanges[0].input[0].display_query().as_deref(),
Some("/test-skill")
);
}
@@ -505,6 +597,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -521,6 +614,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -535,6 +629,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 1
api::Message {
fetched_memories: vec![],
id: "tool_call_1".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -558,6 +653,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 2
api::Message {
fetched_memories: vec![],
id: "tool_call_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -581,6 +677,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call 3
api::Message {
fetched_memories: vec![],
id: "tool_call_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -604,6 +701,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - cancelled (call_2)
api::Message {
fetched_memories: vec![],
id: "result_cancelled".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -620,6 +718,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - success (call_1)
api::Message {
fetched_memories: vec![],
id: "result_success_1".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -639,6 +738,8 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
command_id: "command_1".to_string(),
output: "1".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -650,6 +751,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Tool call result - success (call_3)
api::Message {
fetched_memories: vec![],
id: "result_success_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -669,6 +771,8 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
command_id: "command_2".to_string(),
output: "3".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -680,6 +784,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -694,6 +799,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Follow-up user query
api::Message {
fetched_memories: vec![],
id: "followup_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -710,6 +816,7 @@ fn test_into_exchanges_with_tool_calls_and_cancellation() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -811,6 +918,7 @@ fn test_into_exchanges_with_code_diffs() {
let messages = vec![
// User query asking for code changes
api::Message {
fetched_memories: vec![],
id: "user_query".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -827,6 +935,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -841,6 +950,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// File diff tool call
api::Message {
fetched_memories: vec![],
id: "diff_call".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -862,6 +972,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User cancels the diff
api::Message {
fetched_memories: vec![],
id: "diff_cancelled".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -878,6 +989,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User provides feedback
api::Message {
fetched_memories: vec![],
id: "user_feedback".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -894,6 +1006,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Agent response
api::Message {
fetched_memories: vec![],
id: "agent_response_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -908,6 +1021,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Second file diff tool call
api::Message {
fetched_memories: vec![],
id: "diff_call_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -929,6 +1043,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// User accepts the diff
api::Message {
fetched_memories: vec![],
id: "diff_accepted".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -956,6 +1071,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -970,6 +1086,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Follow-up user query
api::Message {
fetched_memories: vec![],
id: "followup".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -986,6 +1103,7 @@ fn test_into_exchanges_with_code_diffs() {
},
// Final agent response
api::Message {
fetched_memories: vec![],
id: "final_response_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1083,6 +1201,7 @@ fn test_into_exchanges_with_code_diffs() {
fn test_user_query_mode_conversion() {
// Test conversion with Plan mode
let messages = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1129,6 +1248,7 @@ fn test_user_query_mode_conversion() {
// Test conversion with Normal mode (no type set)
let messages_normal = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1173,6 +1293,7 @@ fn test_user_query_mode_conversion() {
// Test conversion with no mode field (should default to Normal)
let messages_default = vec![api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1226,6 +1347,7 @@ fn test_exchanges_grouped_by_request_id() {
let messages = vec![
// Message 0: Server message (should be ignored or handled gracefully)
api::Message {
fetched_memories: vec![],
id: "2512077c-0ede-46b0-8f69-230c8792df07".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1243,6 +1365,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 1: User query with request_id 78e236b8
api::Message {
fetched_memories: vec![],
id: "4d6c450d-3d54-446f-974c-5c414e6083e9".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1259,6 +1382,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 2: Agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "10210d1a-5298-45ef-90ba-df6367805080".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1273,6 +1397,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 3: Tool call with same request_id
api::Message {
fetched_memories: vec![],
id: "936c7c86-eb4a-4edf-97c0-22f5c61b35a6".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "78e236b8-84a2-45df-876e-ebfb86ceafc4".to_string(),
@@ -1296,6 +1421,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 4: Tool call result with NEW request_id 59a3947f (starts new exchange)
api::Message {
fetched_memories: vec![],
id: "cbebf5fb-4dd8-4aef-be45-bb916eff552c".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "59a3947f-fc7e-413a-96b5-baecd7e406dc".to_string(),
@@ -1330,6 +1456,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 5: Agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "7a89857d-fa33-4d45-88e3-5fa9cbce3f20".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "59a3947f-fc7e-413a-96b5-baecd7e406dc".to_string(),
@@ -1344,6 +1471,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 6: Write to long running command with NEW request_id 9f85acb2 (starts new exchange)
api::Message {
fetched_memories: vec![],
id: "dac6d336-9fcb-4e34-bc2b-b06e70f52ec5".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1374,6 +1502,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 7: Final tool call result with same request_id
api::Message {
fetched_memories: vec![],
id: "ad319d66-fac0-4169-8bf1-e6004aca1619".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1395,6 +1524,8 @@ fn test_exchanges_grouped_by_request_id() {
command_id: "cmd1".to_string(),
output: "Done".to_string(),
exit_code: 0,
start_ts: None,
finish_ts: None,
},
)),
},
@@ -1404,6 +1535,7 @@ fn test_exchanges_grouped_by_request_id() {
},
// Message 8: Final agent output with same request_id
api::Message {
fetched_memories: vec![],
id: "f15f8a59-2e9c-416e-b216-83b3bd52d6be".to_string(),
task_id: "d02463e1-2429-48de-ac8f-552df4acc4d0".to_string(),
request_id: "9f85acb2-0b1f-41b1-a0de-3623e131758a".to_string(),
@@ -1492,6 +1624,7 @@ fn test_multiple_create_documents_get_default_version() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1508,6 +1641,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Agent output
api::Message {
fetched_memories: vec![],
id: "agent_text".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1522,6 +1656,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// First CreateDocuments tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1544,6 +1679,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// First CreateDocuments result
api::Message {
fetched_memories: vec![],
id: "result_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1572,6 +1708,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Agent output before second plan
api::Message {
fetched_memories: vec![],
id: "agent_text_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1586,6 +1723,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Second CreateDocuments tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1608,6 +1746,7 @@ fn test_multiple_create_documents_get_default_version() {
},
// Second CreateDocuments result
api::Message {
fetched_memories: vec![],
id: "result_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1694,7 +1833,6 @@ fn test_multiple_create_documents_get_default_version() {
#[test]
fn test_create_then_edit_then_create_version_tracking() {
use crate::ai::agent::{AIAgentActionResultType, CreateDocumentsResult, EditDocumentsResult};
use crate::ai::document::ai_document_model::AIDocumentVersion;
let doc_id_a = uuid::Uuid::new_v4().to_string();
let doc_id_b = uuid::Uuid::new_v4().to_string();
@@ -1702,6 +1840,7 @@ fn test_create_then_edit_then_create_version_tracking() {
let messages = vec![
// User query
api::Message {
fetched_memories: vec![],
id: "user_msg".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1718,6 +1857,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output
api::Message {
fetched_memories: vec![],
id: "agent_text".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1732,6 +1872,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc A tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1754,6 +1895,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc A result
api::Message {
fetched_memories: vec![],
id: "result_create_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1782,6 +1924,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output before edit
api::Message {
fetched_memories: vec![],
id: "agent_text_2".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1796,6 +1939,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Edit doc A tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_edit_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1817,6 +1961,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Edit doc A result
api::Message {
fetched_memories: vec![],
id: "result_edit_a".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1845,6 +1990,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Agent output before second create
api::Message {
fetched_memories: vec![],
id: "agent_text_3".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1859,6 +2005,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc B tool call
api::Message {
fetched_memories: vec![],
id: "tool_call_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1881,6 +2028,7 @@ fn test_create_then_edit_then_create_version_tracking() {
},
// Create doc B result
api::Message {
fetched_memories: vec![],
id: "result_create_b".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
@@ -1979,3 +2127,74 @@ fn test_create_then_edit_then_create_version_tracking() {
"Created doc B should have default version (v1), independent of doc A"
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs.
#[test]
fn test_handoff_rehydration_system_query_is_hidden() {
let messages = vec![
// HandoffRehydration system query should be hidden
api::Message {
fetched_memories: vec![],
id: "msg_handoff".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
citations: vec![],
message: Some(api::message::Message::SystemQuery(
api::message::SystemQuery {
r#type: Some(api::message::system_query::Type::HandoffRehydration(
api::message::HandoffRehydration {
instructions: "restore handoff state".to_string(),
},
)),
context: None,
},
)),
request_id: "req1".to_string(),
timestamp: None,
},
// Agent output that follows the hidden system query
api::Message {
fetched_memories: vec![],
id: "msg_output".to_string(),
task_id: "task1".to_string(),
server_message_data: "".to_string(),
citations: vec![],
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "I have restored the handoff state.".to_string(),
},
)),
request_id: "req1".to_string(),
timestamp: None,
},
];
let task = api::Task {
id: "task1".to_string(),
messages,
dependencies: None,
description: "".to_string(),
summary: "".to_string(),
server_data: "".to_string(),
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1, "Should produce exactly one exchange");
let exchange = &exchanges[0];
// The HandoffRehydration should NOT appear as input
assert!(
exchange.input.is_empty(),
"HandoffRehydration must not produce a displayed input, got: {:?}",
exchange.input
);
// The agent output should still be present
let output = exchange.output_status.output().expect("should have output");
assert!(
!output.get().messages.is_empty(),
"Agent output should still be rendered"
);
}
+137 -36
View File
@@ -2,34 +2,33 @@
use std::collections::HashMap;
use std::time::Duration;
use ai::agent::action::{LifecycleEventType as StartAgentLifecycleEventType, ReadSkillRequest};
use ai::agent::action_result::StartAgentVersion;
use ai::agent::convert::ToolToAIAgentActionError;
use ai::agent::UnknownCitationTypeError;
use ai::skills::{
skill_reference_from_api_skill_ref, skill_reference_from_read_skill_ref, SkillPathOrigin,
};
use api::ask_user_question::question::QuestionType;
use galaxy_core::channel::ChannelState;
use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input,
};
use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{
util::parse_markdown_into_text_and_code_sections, AIAgentAction, AIAgentActionType,
AIAgentCitation, AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo,
ArtifactCreatedData, MessageId, StartAgentExecutionMode, SuggestedAgentModeWorkflow,
SuggestedRule, Suggestions, TodoOperation,
};
use crate::ai::agent::{
CloneRepositoryURL, SubagentCall, SubagentType, SummarizationType, WebFetchStatus,
WebSearchStatus,
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
};
use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use ai::agent::action::LifecycleEventType as StartAgentLifecycleEventType;
use ai::agent::action_result::StartAgentVersion;
use ai::agent::convert::ToolToAIAgentActionError;
use ai::agent::UnknownCitationTypeError;
use ai::skills::SkillReference;
use api::ask_user_question::question::QuestionType;
use galaxy_core::channel::ChannelState;
use warp_multi_agent_api as api;
use crate::ai::agent::{AIAgentAttachment, UserQueryMode};
impl TryFrom<api::Attachment> for AIAgentAttachment {
type Error = anyhow::Error;
@@ -53,6 +52,18 @@ impl TryFrom<api::Attachment> for AIAgentAttachment {
}
}
fn convert_read_skill(
read_skill: api::message::tool_call::ReadSkill,
skill_path_origin: &SkillPathOrigin,
) -> Result<AIAgentActionType, ToolToAIAgentActionError> {
let Some(reference) = read_skill.skill_reference else {
return Err(ToolToAIAgentActionError::MissingSkillReference);
};
let skill = skill_reference_from_read_skill_ref(reference, skill_path_origin)
.map_err(|_| ToolToAIAgentActionError::MissingSkillReference)?;
Ok(AIAgentActionType::ReadSkill(ReadSkillRequest { skill }))
}
/// Converts proto UserQueryMode to the internal UserQueryMode type
pub(crate) fn convert_user_query_mode(mode: Option<&api::UserQueryMode>) -> UserQueryMode {
let Some(mode) = mode else {
@@ -81,6 +92,22 @@ fn convert_start_agent_v2_harness_type(
.filter(|harness_type| !harness_type.trim().is_empty())
}
/// Maps the proto `Harness` oneof to a client-side string identifier
/// (e.g. "oz", "claude"). Returns `None` for an unset variant.
pub(crate) fn convert_run_agents_harness(harness: Option<&api::Harness>) -> Option<String> {
let variant = harness?.variant.as_ref()?;
Some(
match variant {
api::harness::Variant::Oz(_) => "oz",
api::harness::Variant::ClaudeCode(_) => "claude",
api::harness::Variant::OpenCode(_) => "opencode",
api::harness::Variant::Gemini(_) => "gemini",
api::harness::Variant::Codex(_) => "codex",
}
.to_string(),
)
}
fn convert_start_agent_execution_mode(
execution_mode: Option<api::start_agent::ExecutionMode>,
) -> StartAgentExecutionMode {
@@ -94,8 +121,62 @@ fn convert_start_agent_execution_mode(
}
}
fn convert_run_agents_execution_mode(
execution_mode: Option<api::run_agents::ExecutionMode>,
) -> RunAgentsExecutionMode {
match execution_mode {
Some(api::run_agents::ExecutionMode::Remote(remote)) => RunAgentsExecutionMode::Remote {
environment_id: remote.environment_id,
worker_host: remote.worker_host,
computer_use_enabled: remote.computer_use_enabled,
},
Some(api::run_agents::ExecutionMode::Local(_)) | None => RunAgentsExecutionMode::Local,
}
}
fn convert_run_agents(
run_agents: api::RunAgents,
skill_path_origin: &SkillPathOrigin,
) -> AIAgentActionType {
let api::RunAgents {
summary,
base_prompt,
skills,
model_id,
harness,
agent_run_configs,
execution_mode,
plan_id,
} = run_agents;
AIAgentActionType::RunAgents(RunAgentsRequest {
summary,
base_prompt,
skills: skills
.into_iter()
.filter_map(|skill| skill_reference_from_api_skill_ref(skill, skill_path_origin))
.collect(),
model_id,
harness_type: convert_run_agents_harness(harness.as_ref()).unwrap_or_default(),
execution_mode: convert_run_agents_execution_mode(execution_mode),
agent_run_configs: agent_run_configs
.into_iter()
.map(|config| RunAgentsAgentRunConfig {
name: config.name,
prompt: config.prompt,
title: config.title,
})
.collect(),
plan_id,
// Auth secret is a client-side dispatch concern populated by the
// confirmation card from `CloudAgentSettings.last_selected_auth_secret`
// before Accept. The proto does not carry it.
harness_auth_secret_name: None,
})
}
fn convert_start_agent_v2_execution_mode(
execution_mode: Option<api::start_agent_v2::ExecutionMode>,
skill_path_origin: &SkillPathOrigin,
) -> StartAgentExecutionMode {
match execution_mode.and_then(|execution_mode| execution_mode.mode) {
Some(api::start_agent_v2::execution_mode::Mode::Remote(remote)) => {
@@ -104,7 +185,9 @@ fn convert_start_agent_v2_execution_mode(
skill_references: remote
.skills
.into_iter()
.filter_map(convert_skill_reference)
.filter_map(|skill| {
skill_reference_from_api_skill_ref(skill, skill_path_origin)
})
.collect(),
model_id: remote.model_id,
computer_use_enabled: remote.computer_use_enabled,
@@ -112,6 +195,9 @@ fn convert_start_agent_v2_execution_mode(
harness_type: convert_start_agent_v2_harness_type(remote.harness)
.unwrap_or_default(),
title: remote.title,
// Auth secret is plumbed client-side via `RunAgentsRequest`;
// StartAgentV2 from the server never carries it.
auth_secret_name: None,
}
}
Some(api::start_agent_v2::execution_mode::Mode::Local(local)) => {
@@ -123,16 +209,6 @@ fn convert_start_agent_v2_execution_mode(
}
}
fn convert_skill_reference(skill_ref: api::SkillRef) -> Option<SkillReference> {
match skill_ref.skill_reference {
Some(api::skill_ref::SkillReference::Path(path)) => Some(SkillReference::Path(path.into())),
Some(api::skill_ref::SkillReference::BundledSkillId(id)) => {
Some(SkillReference::BundledSkillId(id))
}
None => None,
}
}
/// Unexpected errors when trying to convert an [`api::Message`] to an [`AIAgentOutputMessage`].
#[derive(Debug, thiserror::Error)]
pub enum MessageToAIAgentOutputMessageError {
@@ -167,6 +243,7 @@ pub struct ConversionParams<'a> {
pub task_id: &'a TaskId,
pub current_todo_list: Option<&'a AIAgentTodoList>,
pub active_code_review: Option<&'a CodeReview>,
pub skill_path_origin: &'a SkillPathOrigin,
}
/// Trait for converting an [`api::Message`] to an [`AIAgentOutputMessage`].
@@ -569,7 +646,11 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
| api::message::Message::CodeReview(_)
| api::message::Message::ServerEvent(_)
| api::message::Message::InvokeSkill(_)
| api::message::Message::PassiveSuggestionResult(_) => {
| api::message::Message::PassiveSuggestionResult(_)
// Stage 2 plan-card config snapshot: hydrated separately by the
// plan card's `AIDocumentModel` subscription, not via the
// exchange/output stream. No client output message representation.
| api::message::Message::OrchestrationConfigSnapshot(_) => {
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
}
}
@@ -600,7 +681,7 @@ trait ConvertAPIToolCallToAIAgentAction {
) -> Result<MaybeAIAgentAction, ToolToAIAgentActionError>;
}
/// Trys to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
/// Tries to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
///
/// A [`Result::Error`] indicates an unexpected problem, while [`Ok(None)`]
/// indicates a tool call that we aren't expected to parse.
@@ -700,6 +781,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
create_standard_action(request_computer_use.into())
}
api::message::tool_call::Tool::Subagent(subagent) => {
use api::message::tool_call::subagent::conversation_search_metadata::Target;
use api::message::tool_call::subagent::Metadata;
let subagent_type = match subagent.metadata {
Some(Metadata::Cli(_)) => SubagentType::Cli,
@@ -713,14 +795,23 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
} else {
Some(cs_meta.query)
};
let conversation_id = if cs_meta.conversation_id.is_empty() {
None
} else {
Some(cs_meta.conversation_id)
let (conversation_id, agent_run_id) = match cs_meta.target {
Some(Target::ConversationId(conversation_id))
if !conversation_id.is_empty() =>
{
(Some(conversation_id), None)
}
Some(Target::AgentRunId(agent_run_id)) if !agent_run_id.is_empty() => {
(None, Some(agent_run_id))
}
Some(Target::ConversationId(_))
| Some(Target::AgentRunId(_))
| None => (None, None),
};
SubagentType::ConversationSearch {
query,
conversation_id,
agent_run_id,
}
}
Some(Metadata::WarpDocumentationSearch(_)) => {
@@ -757,6 +848,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
prompt: start_agent.prompt,
execution_mode: convert_start_agent_v2_execution_mode(
start_agent.execution_mode,
params.skill_path_origin,
),
lifecycle_subscription: start_agent.lifecycle_subscription.map(
|subscription| {
@@ -769,6 +861,9 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
),
})
}
api::message::tool_call::Tool::RunAgents(orchestrate) => {
create_standard_action(convert_run_agents(orchestrate, params.skill_path_origin))
}
api::message::tool_call::Tool::SendMessageToAgent(send_message) => {
create_standard_action(AIAgentActionType::SendMessageToAgent {
addresses: send_message.addresses,
@@ -780,7 +875,7 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
create_standard_action(insert_review_comments.into())
}
api::message::tool_call::Tool::ReadSkill(read_skill) => {
create_standard_action(read_skill.try_into()?)
create_standard_action(convert_read_skill(read_skill, params.skill_path_origin)?)
}
api::message::tool_call::Tool::FetchConversation(fetch_conversation) => {
create_standard_action(fetch_conversation.into())
@@ -798,6 +893,12 @@ impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
api::message::tool_call::Tool::Server(_) => {
Ok(MaybeAIAgentAction::NoClientRepresentation)
}
api::message::tool_call::Tool::WaitForEvents(payload) => {
create_standard_action(AIAgentActionType::WaitForEvents {
tool_call_id: self.tool_call_id.clone(),
idle_timeout_seconds: payload.idle_timeout_seconds,
})
}
_ => Err(ToolToAIAgentActionError::UnexpectedTool),
}
}
+27 -4
View File
@@ -1,3 +1,10 @@
use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference};
use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage,
@@ -6,9 +13,6 @@ use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
};
use ai::agent::action::AskUserQuestionType;
use ai::skills::SkillReference;
use warp_multi_agent_api as api;
fn start_agent_tool_call_message(
name: &str,
@@ -17,6 +21,7 @@ fn start_agent_tool_call_message(
lifecycle_subscription_event_types: Option<Vec<i32>>,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -63,6 +68,7 @@ fn start_agent_v2_tool_call_message(
lifecycle_subscription_event_types: Option<Vec<i32>>,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -87,6 +93,7 @@ fn start_agent_v2_tool_call_message(
fn upload_artifact_tool_call_message(path: &str, description: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -140,6 +147,7 @@ fn remote_start_agent_v2_execution_mode(
fn file_artifact_created_message(filepath: &str, description: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: "message-id".to_string(),
task_id: "task-id".to_string(),
server_message_data: String::new(),
@@ -298,6 +306,7 @@ fn converts_start_agent_tool_call_to_action_with_prompt() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -327,6 +336,7 @@ fn converts_local_start_agent_v2_without_harness_type_to_defaults() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -354,6 +364,7 @@ fn converts_upload_artifact_tool_call_to_action() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -377,6 +388,7 @@ fn converts_file_artifact_created_message_with_filename() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -407,6 +419,7 @@ fn converts_start_agent_tool_calls_with_different_prompt_lengths() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("partial conversion should succeed");
let updated_output = updated_message
@@ -414,6 +427,7 @@ fn converts_start_agent_tool_calls_with_different_prompt_lengths() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("updated conversion should succeed");
@@ -442,6 +456,7 @@ fn converts_start_agent_with_explicit_empty_lifecycle_subscription() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -471,6 +486,7 @@ fn converts_start_agent_with_cancelled_and_blocked_lifecycle_subscription() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -503,6 +519,7 @@ fn converts_remote_start_agent_with_environment_id() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -520,6 +537,7 @@ fn converts_remote_start_agent_with_environment_id() {
worker_host: String::new(),
harness_type: String::new(),
title: String::new(),
auth_secret_name: None,
}
);
assert_eq!(lifecycle_subscription, None);
@@ -540,6 +558,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -552,7 +571,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
StartAgentExecutionMode::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![
SkillReference::Path("/tmp/SKILL.md".into()),
SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from("/tmp/SKILL.md",))),
SkillReference::BundledSkillId("review-comments".to_string()),
],
model_id: "gpt-test".to_string(),
@@ -560,6 +579,7 @@ fn converts_remote_start_agent_v2_with_skill_references() {
worker_host: "worker-host".to_string(),
harness_type: "claude-code".to_string(),
title: "Remote child".to_string(),
auth_secret_name: None,
}
);
assert_eq!(lifecycle_subscription, None);
@@ -580,6 +600,7 @@ fn converts_local_start_agent_v2_with_harness_type() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion should succeed");
@@ -599,6 +620,7 @@ fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string());
let reason = "Please finish the interactive flow".to_string();
let message = api::Message {
fetched_memories: vec![],
id: "message".to_string(),
task_id: "task".to_string(),
server_message_data: String::new(),
@@ -622,6 +644,7 @@ fn transfer_control_tool_call_converts_to_action_message() {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("transfer-control conversion should succeed");
+87 -19
View File
@@ -5,14 +5,12 @@ use anyhow::anyhow;
use chrono::{DateTime, Local, Timelike};
use warp_multi_agent_api as api;
use crate::ai::{
agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentAttachment, AIAgentContext,
AIAgentInput, DriveObjectPayload, MCPContext, PassiveSuggestionResultType,
PassiveSuggestionTrigger, RunningCommand, StaticQueryType, Suggestions, UserQueryMode,
},
block_context::BlockContext,
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentAttachment, AIAgentContext, AIAgentInput,
DriveObjectPayload, MCPContext, PassiveSuggestionResultType, PassiveSuggestionTrigger,
RunningCommand, StaticQueryType, Suggestions, UserQueryMode,
};
use crate::ai::block_context::BlockContext;
fn local_datetime_to_timestamp(timestamp: DateTime<Local>) -> prost_types::Timestamp {
prost_types::Timestamp {
@@ -46,11 +44,6 @@ impl TryFrom<StaticQueryType> for api::request::input::query_with_canned_respons
api::request::input::query_with_canned_response::SomethingElse {},
),
),
StaticQueryType::CustomOnboardingRequest => Ok(
api::request::input::query_with_canned_response::Type::CustomOnboardingRequest(
api::request::input::query_with_canned_response::CustomOnboardingRequest {},
),
),
StaticQueryType::EvaluationSuite => {
Err(anyhow::anyhow!("EvaluationSuite StaticQueryType not yet supported").into())
}
@@ -209,9 +202,9 @@ pub(super) fn convert_input(
)),
});
}
AIAgentInput::SummarizeConversation { prompt } => {
AIAgentInput::SummarizeConversation { prompt, context } => {
return Ok(api::request::Input {
context: None,
context: Some(convert_context(context.as_ref())),
r#type: Some(api::request::input::Type::SummarizeConversation(
api::request::input::SummarizeConversation {
prompt: prompt.unwrap_or_default(),
@@ -435,6 +428,19 @@ fn convert_input_to_user_input(
),
)
}
AIAgentInput::OrchestrationConfigUpdate {
plan_id,
config,
status,
} => Ok(
api::request::input::user_inputs::user_input::Input::OrchestrationConfigUpdate(
api::OrchestrationConfigUpdate {
plan_id,
config: Some(config.to_proto()),
status: status.to_proto(),
},
),
),
AIAgentInput::ResumeConversation { .. } => Err(ConvertToAPITypeError::Ignore),
AIAgentInput::InitProjectRules { .. } => Err(ConvertToAPITypeError::Ignore),
AIAgentInput::CodeReview { .. } => Err(ConvertToAPITypeError::Ignore),
@@ -692,6 +698,12 @@ impl TryFrom<AIAgentActionResult> for api::request::input::user_inputs::user_inp
AIAgentActionResultType::AskUserQuestion(ask_user_question_result) => {
Some(ask_user_question_result.into())
}
AIAgentActionResultType::RunAgents(orchestrate_result) => {
Some(orchestrate_result.try_into()?)
}
AIAgentActionResultType::WaitForEvents(wait_for_events_result) => {
Some(wait_for_events_result.try_into()?)
}
};
Ok(
api::request::input::user_inputs::user_input::Input::ToolCallResult(
@@ -706,6 +718,7 @@ impl TryFrom<AIAgentActionResult> for api::request::input::user_inputs::user_inp
fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
let mut api_context = api::InputContext::default();
let mut git_context = None;
for context in context.iter().cloned() {
match context {
AIAgentContext::Block(block) => {
@@ -789,11 +802,40 @@ fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
}
}
AIAgentContext::Git { head, branch } => {
api_context.git = Some(api::input_context::Git {
head,
branch: branch.unwrap_or_default(),
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.head = head;
api_git_context.branch = branch.unwrap_or_default();
}
AIAgentContext::Repository { name, owner } => {
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.repository = Some(api::input_context::git::Repository {
name,
owner: owner.unwrap_or_default(),
});
}
AIAgentContext::PullRequest {
number,
state,
draft,
base_branch,
} => {
if number <= 0 {
continue;
}
let Some(state) = api_pull_request_state(&state, draft) else {
continue;
};
let pull_request = api::input_context::git::PullRequest {
number,
state: state as i32,
base_branch,
};
let api_git_context =
git_context.get_or_insert_with(api::input_context::Git::default);
api_git_context.pull_request = Some(pull_request);
}
AIAgentContext::Skills { skills } => {
api_context.updated_skills_context = Some(api::input_context::SkillsContext {
available_skills: skills
@@ -810,9 +852,34 @@ fn convert_context(context: &[AIAgentContext]) -> api::InputContext {
}
}
}
api_context.git = git_context;
api_context
}
/// Maps a GitHub PR state plus draft flag to the proto `State` enum.
///
/// Returns `None` for unknown states so the caller can skip emitting a
/// `pull_request` sub-message rather than sending `STATE_UNSPECIFIED` to the
/// server.
fn api_pull_request_state(
state: &str,
draft: bool,
) -> Option<api::input_context::git::pull_request::State> {
use api::input_context::git::pull_request::State;
match state.to_ascii_uppercase().as_str() {
"OPEN" => {
if draft {
Some(State::OpenDraft)
} else {
Some(State::Open)
}
}
"CLOSED" => Some(State::Closed),
"MERGED" => Some(State::Merged),
_ => None,
}
}
impl From<Suggestions> for api::Suggestions {
fn from(value: Suggestions) -> Self {
Self {
@@ -940,12 +1007,13 @@ impl From<BlockContext> for api::ExecutedShellCommand {
}
}
/// Trys to convert a [`serde_json::Value`] to a [`prost_types::Value`].
/// Tries to convert a [`serde_json::Value`] to a [`prost_types::Value`].
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fn serde_json_to_prost(value: serde_json::Value) -> Result<prost_types::Value, String> {
use std::collections::BTreeMap;
use prost_types::value::Kind::*;
use serde_json::Value::*;
use std::collections::BTreeMap;
Ok(prost_types::Value {
kind: Some(match value {
+111 -5
View File
@@ -1,11 +1,111 @@
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, TransferShellCommandControlToUserResult,
};
use crate::terminal::model::block::BlockId;
use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext,
TransferShellCommandControlToUserResult,
};
use crate::terminal::model::block::BlockId;
#[test]
fn git_context_converts_repository_and_pull_request_metadata() {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::Repository {
name: "warp-internal".to_string(),
owner: Some("warpdotdev".to_string()),
},
AIAgentContext::PullRequest {
number: 42,
state: "OPEN".to_string(),
draft: true,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.head, "abc123");
assert_eq!(git.branch, "feature/repo-pr");
let repository = git.repository.expect("expected repository context");
assert_eq!(repository.name, "warp-internal");
assert_eq!(repository.owner, "warpdotdev");
let pull_request = git.pull_request.expect("expected pull request context");
assert_eq!(pull_request.number, 42);
assert_eq!(
pull_request.state,
api::input_context::git::pull_request::State::OpenDraft as i32
);
assert_eq!(pull_request.base_branch, "main");
}
#[test]
fn git_context_skips_pull_request_metadata_with_invalid_number() {
for number in [0, -1] {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::PullRequest {
number,
state: "OPEN".to_string(),
draft: false,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.head, "abc123");
assert_eq!(git.branch, "feature/repo-pr");
assert_eq!(git.pull_request, None);
}
}
#[test]
fn git_context_skips_pull_request_metadata_with_unknown_state() {
let context = vec![
AIAgentContext::Git {
head: "abc123".to_string(),
branch: Some("feature/repo-pr".to_string()),
},
AIAgentContext::PullRequest {
number: 42,
state: "SOMETHING_ELSE".to_string(),
draft: false,
base_branch: "main".to_string(),
},
];
let api_context = super::convert_context(&context);
let git = api_context.git.expect("expected git context");
assert_eq!(git.pull_request, None);
}
#[test]
fn git_context_deserializes_legacy_string_pull_request_number() {
let pull_request = serde_json::from_str::<AIAgentContext>(
r#"{"PullRequest":{"number":"42","state":"OPEN","draft":false,"base_branch":"main"}}"#,
)
.expect("expected legacy serialized pull request context");
let api_context = super::convert_context(&[pull_request]);
let pull_request = api_context
.git
.expect("expected git context")
.pull_request
.expect("expected pull request context");
assert_eq!(pull_request.number, 42);
}
#[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
@@ -51,6 +151,8 @@ fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
#[test]
fn transfer_control_finished_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default();
let start_ts = DateTime::from(Utc::now());
let completed_ts = DateTime::from(Utc::now());
let input =
api::request::input::user_inputs::user_input::Input::try_from(AIAgentActionResult {
id: "tool_call".to_string().into(),
@@ -60,6 +162,8 @@ fn transfer_control_finished_result_converts_to_tool_call_result_input() {
block_id: block_id.clone(),
output: "done".to_string(),
exit_code: ExitCode::from(17),
start_ts: Some(start_ts),
completed_ts: Some(completed_ts),
},
),
})
@@ -78,6 +182,8 @@ fn transfer_control_finished_result_converts_to_tool_call_result_input() {
assert_eq!(finished.command_id, block_id.to_string());
assert_eq!(finished.output, "done");
assert_eq!(finished.exit_code, 17);
assert_eq!(finished.start_ts, Some(super::local_datetime_to_timestamp(start_ts)));
assert_eq!(finished.finish_ts, Some(super::local_datetime_to_timestamp(completed_ts)));
}
other => panic!("Expected command-finished result, got {other:?}"),
},
+88 -54
View File
@@ -1,15 +1,15 @@
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use crate::{ai::agent::redaction, terminal::model::session::SessionType};
use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use crate::ai::bedrock::translator::{self, TranslatorRequest};
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use super::{convert_to::convert_input, ConvertToAPITypeError, RequestParams, ResponseStream};
use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
use crate::ai::agent::redaction;
use crate::server::server_api::{AIApiError, ServerApi};
use crate::terminal::model::session::SessionType;
pub async fn generate_multi_agent_output(
provider_config: ProviderConfig,
@@ -53,10 +53,10 @@ pub async fn generate_multi_agent_output(
redaction::redact_inputs(&mut params.input);
}
let mut api_keys = params.api_keys;
if let Some(api_keys) = &mut api_keys {
api_keys.allow_use_of_warp_credits = params.allow_use_of_warp_credits_with_byok;
}
let api_keys = api_keys_with_warp_credit_fallback_setting(
params.api_keys,
params.allow_use_of_warp_credits,
);
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
@@ -68,6 +68,7 @@ pub async fn generate_multi_agent_output(
base: params.model.into(),
cli_agent: params.cli_agent_model.into(),
computer_use_agent: params.computer_use_model.into(),
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
..Default::default()
}),
rules_enabled: params.is_memory_enabled,
@@ -99,7 +100,11 @@ pub async fn generate_multi_agent_output(
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
supports_research_agent: params.research_agent_enabled,
supports_orchestration_v2: FeatureFlag::OrchestrationV2.is_enabled(),
supports_orchestration_v2: supports_orchestration_v2(params.orchestration_enabled),
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
&& computer_use::background_supported(),
custom_model_providers: params.custom_model_providers,
custom_model_routers: params.custom_model_routers,
}),
metadata: Some(api::request::Metadata {
logging: logging_metadata,
@@ -113,6 +118,8 @@ pub async fn generate_multi_agent_output(
.map(|id| id.to_string())
.unwrap_or_default(),
forked_from_conversation_id: if params.conversation_token.is_none() {
// We only include this param on our initial request to the server
// (when the forked conversation has not been assigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
@@ -129,41 +136,19 @@ pub async fn generate_multi_agent_output(
mcp_context: params.mcp_context.map(Into::into),
};
let model_id = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
match provider_config {
ProviderConfig::Bedrock(config) => {
let translator_request = TranslatorRequest {
config,
model_id,
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
};
match translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
let response_stream =
warp_multi_agent_client::generate_multi_agent_output(server_api.as_ref(), &request).await;
match response_stream {
Ok(stream) => {
let output_stream = stream
.then(|result| async {
match result {
Ok(event) => Ok(event),
Err(error) => Err(convert_multi_agent_client_error(error).await),
}
})
.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
@@ -202,12 +187,53 @@ pub async fn generate_multi_agent_output(
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
let _ = tx
.send(Err(convert_multi_agent_client_error(e).await))
.await;
Ok(Box::pin(rx))
}
}
}
async fn convert_multi_agent_client_error(
error: warp_multi_agent_client::Error,
) -> Arc<AIApiError> {
let error = match error {
warp_multi_agent_client::Error::Authentication(error)
| warp_multi_agent_client::Error::AmbientHeaders(error) => AIApiError::Other(error),
warp_multi_agent_client::Error::Base64Decode(error) => {
AIApiError::Other(anyhow::Error::from(error))
}
warp_multi_agent_client::Error::ProtobufDecode(error) => {
AIApiError::Other(anyhow::Error::from(error))
}
warp_multi_agent_client::Error::EventSource(error) => {
AIApiError::from_stream_error("GenerateMultiAgentOutput", *error).await
}
};
Arc::new(error)
}
fn api_keys_with_warp_credit_fallback_setting(
api_keys: Option<api::request::settings::ApiKeys>,
allow_use_of_warp_credits: bool,
) -> Option<api::request::settings::ApiKeys> {
match api_keys {
Some(mut api_keys) => {
api_keys.allow_use_of_warp_credits = allow_use_of_warp_credits;
Some(api_keys)
}
None if allow_use_of_warp_credits => Some(api::request::settings::ApiKeys {
allow_use_of_warp_credits: true,
..Default::default()
}),
None => None,
}
}
fn supports_orchestration_v2(orchestration_enabled: bool) -> bool {
orchestration_enabled
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
let mut supported_tools = vec![
api::ToolType::Grep,
@@ -245,7 +271,14 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
// Remote session with a known host — enable tools that route
// through RemoteServerClient. The host_id is only populated
// after a successful connection handshake, so its presence is a
// sufficient proxy for client availability.
supported_tools.extend(&[api::ToolType::ReadFiles, api::ToolType::ApplyFileDiffs]);
if FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
supported_tools.push(api::ToolType::SearchCodebase);
}
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {}
}
@@ -264,12 +297,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
if params.orchestration_enabled {
supported_tools.push(if FeatureFlag::OrchestrationV2.is_enabled() {
api::ToolType::StartAgentV2
} else {
api::ToolType::StartAgent
});
supported_tools.push(api::ToolType::SendMessageToAgent);
supported_tools.extend([api::ToolType::RunAgents, api::ToolType::SendMessageToAgent]);
// Declare client-handled wait_for_events so the server doesn't
// fall back to the legacy server-handled form.
supported_tools.push(api::ToolType::WaitForEvents);
}
if FeatureFlag::AskUserQuestion.is_enabled() && params.ask_user_question_enabled {
@@ -299,6 +330,9 @@ fn get_supported_cli_agent_tools(params: &RequestParams) -> Vec<api::ToolType> {
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
supported_cli_agent_tools.push(api::ToolType::ReadFiles);
if FeatureFlag::RemoteCodebaseIndexing.is_enabled() {
supported_cli_agent_tools.push(api::ToolType::SearchCodebase);
}
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {}
}
+124 -5
View File
@@ -1,10 +1,15 @@
use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId;
use warp_multi_agent_api as api;
use super::{
api_keys_with_warp_credit_fallback_setting, get_supported_cli_agent_tools, get_supported_tools,
supports_orchestration_v2,
};
use crate::ai::agent::api::RequestParams;
use crate::ai::blocklist::SessionContext;
use crate::ai::llms::LLMId;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use super::get_supported_tools;
use crate::terminal::model::session::SessionType;
fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool) -> RequestParams {
let model = LLMId::from("test-model");
@@ -24,11 +29,14 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
computer_use_model: model,
is_memory_enabled: false,
warp_drive_context_enabled: false,
context_window_limit: None,
mcp_context: None,
planning_enabled: true,
should_redact_secrets: false,
api_keys: None,
allow_use_of_warp_credits_with_byok: false,
custom_model_providers: None,
custom_model_routers: None,
allow_use_of_warp_credits: false,
autonomy_level: api::AutonomyLevel::Supervised,
isolation_level: api::IsolationLevel::None,
web_search_enabled: false,
@@ -47,6 +55,85 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
}
}
fn request_params_for_remote(host_id: Option<HostId>) -> RequestParams {
let mut params = request_params_with_ask_user_question_enabled(false);
params.session_context =
SessionContext::new_with_session_type_for_test(Some(SessionType::WarpifiedRemote {
host_id,
}));
params
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_returns_none_without_keys_or_fallback() {
let api_keys = api_keys_with_warp_credit_fallback_setting(None, false);
assert!(api_keys.is_none());
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_creates_fallback_only_api_keys() {
let api_keys = api_keys_with_warp_credit_fallback_setting(None, true)
.expect("fallback setting should create ApiKeys");
assert!(api_keys.allow_use_of_warp_credits);
assert!(api_keys.anthropic.is_empty());
assert!(api_keys.openai.is_empty());
assert!(api_keys.google.is_empty());
assert!(api_keys.open_router.is_empty());
assert!(api_keys.aws_credentials.is_none());
}
#[test]
fn api_keys_with_warp_credit_fallback_setting_preserves_existing_keys() {
let api_keys = api_keys_with_warp_credit_fallback_setting(
Some(api::request::settings::ApiKeys {
anthropic: "anthropic-key".to_string(),
openai: String::new(),
google: String::new(),
open_router: String::new(),
grok_oauth_access_token: String::new(),
allow_use_of_warp_credits: false,
aws_credentials: None,
google_cloud_credentials: None,
}),
true,
)
.expect("existing ApiKeys should be preserved");
assert_eq!(api_keys.anthropic, "anthropic-key");
assert!(api_keys.allow_use_of_warp_credits);
}
#[test]
fn supports_orchestration_v2_matches_request_orchestration_setting() {
assert!(supports_orchestration_v2(true));
assert!(!supports_orchestration_v2(false));
}
#[test]
fn supported_tools_include_orchestration_tools_when_orchestration_enabled() {
let mut params = request_params_with_ask_user_question_enabled(false);
params.orchestration_enabled = true;
let supported_tools = get_supported_tools(&params);
assert!(supported_tools.contains(&api::ToolType::RunAgents));
assert!(supported_tools.contains(&api::ToolType::SendMessageToAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_omit_orchestration_tools_when_orchestration_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::RunAgents));
assert!(!supported_tools.contains(&api::ToolType::SendMessageToAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgent));
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_omits_ask_user_question_when_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
@@ -84,3 +171,35 @@ fn supported_tools_omit_upload_artifact_when_feature_flag_is_disabled() {
assert!(!supported_tools.contains(&api::ToolType::UploadFileArtifact));
}
#[test]
fn remote_supported_tools_include_search_codebase_when_connected_and_feature_flag_is_enabled() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
let params = request_params_for_remote(Some(HostId::new("host".to_string())));
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}
#[test]
fn remote_supported_tools_omit_search_codebase_when_feature_flag_is_disabled() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(false);
let params = request_params_for_remote(Some(HostId::new("host".to_string())));
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(!supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}
#[test]
fn remote_supported_tools_omit_search_codebase_when_remote_is_not_connected() {
let _flag = FeatureFlag::RemoteCodebaseIndexing.override_enabled(true);
let params = request_params_for_remote(None);
let supported_tools = get_supported_tools(&params);
let supported_cli_agent_tools = get_supported_cli_agent_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::SearchCodebase));
assert!(!supported_cli_agent_tools.contains(&api::ToolType::SearchCodebase));
}