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
+105 -25
View File
@@ -3,40 +3,38 @@ mod convert_from;
mod convert_to;
mod r#impl;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
pub use ai::agent::convert::ConvertToAPITypeError;
use ai::api_keys::ApiKeyManager;
pub use convert_from::{
user_inputs_from_messages, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
};
pub use r#impl::generate_multi_agent_output;
use futures_lite::Stream;
use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output;
use serde::Serialize;
use galaxy_core::channel::ChannelState;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
use serde::Serialize;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::{
ai::{blocklist::SessionContext, llms::LLMId},
server::server_api::AIApiError,
};
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput};
use crate::ai::mcp::templatable_manager::TemplatableMCPServerInfo;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AIExecutionProfileAppExt;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::server::server_api::AIApiError;
use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _};
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation.
@@ -109,13 +107,21 @@ pub struct RequestParams {
pub computer_use_model: LLMId,
pub is_memory_enabled: bool,
pub warp_drive_context_enabled: bool,
pub context_window_limit: Option<u32>,
pub mcp_context: Option<MCPContext>,
pub planning_enabled: bool,
should_redact_secrets: bool,
/// User-provided API keys for AI providers (BYO API Key).
pub api_keys: Option<warp_multi_agent_api::request::settings::ApiKeys>,
pub allow_use_of_warp_credits_with_byok: bool,
/// User-provided custom model providers (BYOK endpoints).
pub custom_model_providers:
Option<warp_multi_agent_api::request::settings::CustomModelProviders>,
/// User-defined custom model routers referenced by the current selection. Mirrors
/// `custom_model_providers`: the selected model's `config_key` indexes into this
/// registry. `None` when no custom router is selected.
pub custom_model_routers: Option<warp_multi_agent_api::request::settings::CustomModelRouters>,
pub allow_use_of_warp_credits: bool,
pub autonomy_level: warp_multi_agent_api::AutonomyLevel,
pub isolation_level: warp_multi_agent_api::IsolationLevel,
pub web_search_enabled: bool,
@@ -170,6 +176,44 @@ pub struct ConversationData {
}
impl RequestParams {
#[cfg(test)]
pub fn new_for_test() -> Self {
Self {
input: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
tasks: vec![],
existing_suggestions: None,
metadata: None,
session_context: SessionContext::new_for_test(),
model: LLMId::from("test-model"),
coding_model: LLMId::from("test-model"),
cli_agent_model: LLMId::from("test-model"),
computer_use_model: LLMId::from("test-model"),
is_memory_enabled: false,
warp_drive_context_enabled: false,
context_window_limit: None,
mcp_context: None,
planning_enabled: false,
should_redact_secrets: false,
api_keys: None,
custom_model_providers: None,
custom_model_routers: None,
allow_use_of_warp_credits: false,
autonomy_level: Default::default(),
isolation_level: Default::default(),
web_search_enabled: false,
computer_use_enabled: false,
ask_user_question_enabled: false,
research_agent_enabled: false,
orchestration_enabled: false,
supported_tools_override: None,
parent_agent_id: None,
agent_name: None,
}
}
pub fn new(
terminal_view_id: Option<EntityId>,
session_context: SessionContext,
@@ -251,12 +295,31 @@ impl RequestParams {
let should_redact_secrets = get_secret_obfuscation_mode(app).should_redact_secret();
let user_workspaces = UserWorkspaces::as_ref(app);
let api_keys = ApiKeyManager::as_ref(app).api_keys_for_request(
user_workspaces.is_byo_api_key_enabled(),
user_workspaces.is_bedrock_enabled(app),
let api_key_manager = ApiKeyManager::as_ref(app);
let is_byo_enabled = user_workspaces.is_byo_api_key_enabled(app);
#[cfg(not(target_family = "wasm"))]
let geap_binding = crate::ai::geap_credentials::current_geap_policy(app).mint_binding();
#[cfg(target_family = "wasm")]
let geap_binding: Option<::ai::api_keys::GeapMintBinding> = None;
let api_keys = api_key_manager.api_keys_for_request(
is_byo_enabled,
user_workspaces.is_aws_bedrock_credentials_enabled(app),
geap_binding,
);
let allow_use_of_warp_credits_with_byok =
*AISettings::as_ref(app).can_use_warp_credits_with_byok;
let is_custom_inference_enabled = user_workspaces.is_custom_inference_enabled(app);
let custom_model_providers = FeatureFlag::CustomInferenceEndpoints
.is_enabled()
.then(|| {
api_key_manager.custom_model_providers_for_request(is_custom_inference_enabled)
})
.flatten();
let custom_model_routers = FeatureFlag::CustomModelRouters.is_enabled().then(|| {
LLMPreferences::as_ref(app).custom_model_routers_for_request(
&request_input.model_id,
&request_input.coding_model_id,
)
});
let allow_use_of_warp_credits = *AISettings::as_ref(app).can_use_warp_credits_for_fallback;
let app_execution_mode = AppExecutionMode::as_ref(app);
let autonomy_level = if app_execution_mode.is_autonomous() {
@@ -292,11 +355,25 @@ impl RequestParams {
!= crate::ai::execution_profiles::AskUserQuestionPermission::Never;
let orchestration_enabled = ai_settings.is_orchestration_enabled(app)
&& BlocklistAIPermissions::as_ref(app)
.get_run_agents_setting(app, terminal_view_id)
.is_enabled()
&& session_context
.session_type()
.as_ref()
.is_none_or(|t| matches!(t, crate::terminal::model::session::SessionType::Local));
// Reconcile the persisted override against the active base model's
// current `LLMContextWindow` instead of trusting whatever was stored
// last. If the active model isn't configurable or has been removed
// server-side, drop the override; otherwise clamp it to the model's
// current `[min, max]` range. This closes the window between an
// in-flight model metadata refresh and the next request.
let context_window_limit = AIExecutionProfilesModel::as_ref(app)
.active_profile(terminal_view_id, app)
.data()
.context_window_limit_for_request(app);
Self {
input: request_input.all_inputs().cloned().collect(),
conversation_token: conversation.server_conversation_token,
@@ -304,6 +381,7 @@ impl RequestParams {
ambient_agent_task_id: conversation.ambient_agent_task_id,
tasks: conversation.tasks,
existing_suggestions: conversation.existing_suggestions,
context_window_limit,
metadata,
session_context,
model: request_input.model_id.clone(),
@@ -316,7 +394,9 @@ impl RequestParams {
planning_enabled: true,
should_redact_secrets,
api_keys,
allow_use_of_warp_credits_with_byok,
custom_model_providers,
custom_model_routers,
allow_use_of_warp_credits,
autonomy_level,
isolation_level,
web_search_enabled,
+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));
}
+6 -10
View File
@@ -1,5 +1,5 @@
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code_review::comments::CommentId;
use std::path::PathBuf;
/// The current state of a code review.
#[derive(Debug, Clone, Default)]
@@ -31,18 +31,14 @@ impl ReviewComment {
pub fn title(&self) -> String {
match (&self.diff.file_path, self.diff.line_number) {
(Some(file_path), Some(line_number)) => {
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Invalid File Name");
let path_component = file_path.path_component();
let file_name = path_component.file_name().unwrap_or("Invalid File Name");
let display_line = line_number + 1;
format!("{file_name}:{display_line}")
}
(Some(file_path), None) => {
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Invalid File Name");
let path_component = file_path.path_component();
let file_name = path_component.file_name().unwrap_or("Invalid File Name");
file_name.to_string()
}
(None, _) => self
@@ -101,6 +97,6 @@ impl From<crate::code_review::comments::AttachedReviewCommentTarget> for ReviewD
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ReviewDiff {
pub file_path: Option<PathBuf>,
pub file_path: Option<LocalOrRemotePath>,
pub line_number: Option<usize>,
}
File diff suppressed because it is too large Load Diff
+692 -5
View File
@@ -1,12 +1,23 @@
use std::collections::HashMap;
use super::{
artifact_from_fork_proto, AIConversation, AIConversationAutoexecuteMode, AIConversationId,
};
use crate::ai::artifacts::Artifact;
use crate::persistence::model::AgentConversationData;
use ai::api_keys::ApiKeyManager;
use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use warpui::{App, SingletonEntity};
use super::{
artifact_from_fork_proto, footer_model_token_usage, AIConversation,
AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, RestoreConversationError,
};
use crate::ai::artifacts::Artifact;
use crate::ai::llms::LLMPreferences;
use crate::auth::auth_manager::AuthManager;
use crate::auth::AuthStateProvider;
use crate::network::NetworkStatus;
use crate::persistence::model::AgentConversationData;
use crate::server::server_api::ServerApiProvider;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::user_workspaces::UserWorkspaces;
fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AIConversation {
AIConversation::new_restored(
@@ -24,8 +35,25 @@ fn restored_conversation(conversation_data: Option<AgentConversationData>) -> AI
.unwrap()
}
fn restored_conversation_with_root_description(description: &str) -> AIConversation {
AIConversation::new_restored(
AIConversationId::new(),
vec![api::Task {
id: "root-task".to_string(),
messages: vec![],
dependencies: None,
description: description.to_string(),
summary: String::new(),
server_data: String::new(),
}],
None,
)
.unwrap()
}
fn user_query_message(id: &str, request_id: &str, query: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: "root-task".to_string(),
server_message_data: String::new(),
@@ -44,6 +72,7 @@ fn user_query_message(id: &str, request_id: &str, query: &str) -> api::Message {
fn agent_output_message(id: &str, request_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: "root-task".to_string(),
server_message_data: String::new(),
@@ -86,6 +115,43 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap()
}
fn initialize_custom_endpoint_usage_test_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AuthManager::new_for_test);
}
#[allow(deprecated)]
fn custom_endpoint_usage_metadata(
config_key: &str,
total_tokens: u32,
) -> api::response_event::stream_finished::ConversationUsageMetadata {
let category = "primary_agent".to_string();
api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
context_window_segments: Vec::new(),
custom_endpoint_token_usage: HashMap::from([(
config_key.to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: config_key.to_string(),
total_tokens,
token_usage_by_category: HashMap::from([(category, total_tokens)]),
},
)]),
}
}
#[test]
fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation =
@@ -107,6 +173,49 @@ fn latest_user_query_trims_and_skips_empty_queries() {
);
}
#[test]
fn title_uses_root_task_description() {
let conversation = restored_conversation_with_root_description("Root task title");
assert_eq!(conversation.title().as_deref(), Some("Root task title"));
}
#[test]
fn title_falls_back_to_initial_query_when_root_description_is_empty() {
let conversation = restored_conversation_with_queries(&["Initial query"]);
assert_eq!(conversation.title().as_deref(), Some("Initial query"));
}
#[test]
fn reassign_exchange_ids_keeps_exchange_lookup_consistent() {
let mut conversation = restored_conversation_with_queries(&["one", "two"]);
let old_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect();
assert!(!old_ids.is_empty());
// Pre-condition: every original id resolves via the exchange-id index.
for id in &old_ids {
assert!(conversation.exchange_with_id(*id).is_some());
}
conversation.reassign_exchange_ids();
// Reassigning regenerates ids without changing the exchange count, so
// `modify_task` does not rebuild the index; correctness relies on the
// explicit `rebuild_exchange_id_index()` call. The stale ids must be gone.
for id in &old_ids {
assert!(conversation.exchange_with_id(*id).is_none());
}
// Every current id resolves via the rebuilt index.
let new_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect();
assert_eq!(new_ids.len(), old_ids.len());
for id in &new_ids {
assert!(conversation.exchange_with_id(*id).is_some());
}
}
#[test]
fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true);
@@ -121,6 +230,416 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
);
}
#[test]
fn restored_conversation_uses_persisted_last_event_sequence() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null,"last_event_sequence":42}"#)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.last_event_sequence(), Some(42));
}
#[test]
fn restored_conversation_uses_persisted_remote_child_marker() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null,"is_remote_child":true}"#)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert!(conversation.is_remote_child());
}
#[test]
fn child_conversation_detection_uses_parent_agent_id() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"parent_agent_id":"parent-run-id"}"#,
)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert!(conversation.is_child_agent_conversation());
assert_eq!(conversation.parent_conversation_id(), None);
}
/// When the persisted task list is empty (e.g. a child conversation persisted
/// before any server response), restoring via `new_restored_synthesizing_on_empty`
/// must produce a fresh in-progress optimistic root, mirroring
/// `AIConversation::new()`.
#[test]
fn restored_conversation_with_empty_task_list_creates_in_progress_optimistic_root() {
let conversation =
AIConversation::new_restored_synthesizing_on_empty(AIConversationId::new(), vec![], None)
.expect("empty task list must synthesize an optimistic root");
let root_task = conversation
.get_root_task()
.expect("synthesized root task should exist");
assert!(root_task.is_root_task());
assert!(
root_task.source().is_none(),
"synthesized root is optimistic and has no api::Task source"
);
assert!(
!root_task.id().to_string().is_empty(),
"synthesized optimistic root must have a non-empty UUID id"
);
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
assert!(conversation.status_error_message().is_none());
}
#[test]
fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
manager.add_custom_endpoint(
"Endpoint".to_string(),
"https://custom.example".to_string(),
"key".to_string(),
vec![(
"raw-model".to_string(),
Some("Friendly alias".to_string()),
Some("config-key".to_string()),
)],
ctx,
);
});
app.add_singleton_model(LLMPreferences::new);
let mut conversation = AIConversation::new(false, false);
app.read(|ctx| {
conversation
.update_cost_and_usage_for_request(
None,
vec![],
Some(custom_endpoint_usage_metadata("config-key", 6)),
false,
ctx,
)
.expect("custom endpoint usage should update");
});
let usage = conversation
.token_usage()
.iter()
.find(|usage| usage.model_id == "Friendly alias")
.expect("custom endpoint alias should resolve into footer usage");
assert_eq!(usage.custom_endpoint_tokens, 6);
assert_eq!(usage.byok_tokens, 0);
assert_eq!(
usage
.custom_endpoint_token_usage_by_category
.get("primary_agent"),
Some(&6)
);
});
}
#[test]
fn update_cost_and_usage_uses_fallback_label_for_unknown_custom_endpoint() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
app.add_singleton_model(LLMPreferences::new);
let mut conversation = AIConversation::new(false, false);
app.read(|ctx| {
conversation
.update_cost_and_usage_for_request(
None,
vec![],
Some(custom_endpoint_usage_metadata("missing-config-key", 9)),
false,
ctx,
)
.expect("fallback custom endpoint usage should update");
});
let usage = conversation
.token_usage()
.iter()
.find(|usage| usage.model_id == "Custom endpoint")
.expect("unknown custom endpoint usage should use the fallback label");
assert_eq!(usage.custom_endpoint_tokens, 9);
assert_eq!(usage.byok_tokens, 0);
assert_eq!(
usage
.custom_endpoint_token_usage_by_category
.get("primary_agent"),
Some(&9)
);
});
}
#[allow(deprecated)]
#[test]
fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_labeled_models() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
manager.add_custom_endpoint(
"Endpoint".to_string(),
"https://custom.example".to_string(),
"key".to_string(),
vec![(
"raw-model".to_string(),
Some("Resolved custom".to_string()),
Some("config-key".to_string()),
)],
ctx,
);
});
app.add_singleton_model(LLMPreferences::new);
let category = "primary_agent".to_string();
let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
#[allow(deprecated)]
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::from([(
"Resolved custom".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "Resolved custom".to_string(),
total_tokens: 4,
token_usage_by_category: HashMap::from([(category.clone(), 4)]),
},
)]),
custom_endpoint_token_usage: HashMap::from([(
"config-key".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "config-key".to_string(),
total_tokens: 6,
token_usage_by_category: HashMap::from([(category.clone(), 6)]),
},
)]),
context_window_segments: Vec::new(),
};
let model_usage =
app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx)));
let byok_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Resolved custom" && usage.byok_tokens == 4)
.expect("existing model usage should be present");
let custom_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Resolved custom" && usage.custom_endpoint_tokens == 6)
.expect("custom endpoint usage should remain distinct");
assert_eq!(model_usage.len(), 2);
assert_eq!(
byok_usage.byok_token_usage_by_category.get(&category),
Some(&4)
);
assert_eq!(
custom_usage
.custom_endpoint_token_usage_by_category
.get(&category),
Some(&6)
);
assert_eq!(byok_usage.warp_tokens, 0);
assert_eq!(custom_usage.warp_tokens, 0);
assert_eq!(custom_usage.byok_tokens, 0);
});
}
#[allow(deprecated)]
#[test]
fn footer_model_token_usage_preserves_unresolved_custom_endpoint_usage_with_fallback_label() {
App::test((), |mut app| async move {
initialize_custom_endpoint_usage_test_app(&mut app);
app.add_singleton_model(LLMPreferences::new);
let category = "primary_agent".to_string();
let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
summarized: false,
#[allow(deprecated)]
token_usage: vec![],
tool_usage_metadata: None,
total_input_tokens: 0,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
custom_endpoint_token_usage: HashMap::from([(
"missing-config-key".to_string(),
api::response_event::stream_finished::ModelTokenUsage {
model_id: "missing-config-key".to_string(),
total_tokens: 9,
token_usage_by_category: HashMap::from([(category.clone(), 9)]),
},
)]),
context_window_segments: Vec::new(),
};
let model_usage =
app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx)));
let custom_usage = model_usage
.iter()
.find(|usage| usage.model_id == "Custom endpoint")
.expect("fallback custom endpoint usage should be present");
assert_eq!(model_usage.len(), 1);
assert_eq!(custom_usage.custom_endpoint_tokens, 9);
assert_eq!(custom_usage.byok_tokens, 0);
assert_eq!(
custom_usage
.custom_endpoint_token_usage_by_category
.get(&category),
Some(&9)
);
assert_eq!(custom_usage.warp_tokens, 0);
});
}
/// The legacy `AgentConversationData.root_task_is_optimistic` flag must be
/// ignored on restore. A non-empty task list always produces a real
/// server-backed root regardless of whether the flag is set.
#[test]
fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_non_empty_tasks() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#,
)
.unwrap();
let conversation = restored_conversation(Some(conversation_data));
let root_task = conversation
.get_root_task()
.expect("root task should exist");
assert_eq!(root_task.id().to_string(), "root-task");
assert!(root_task.is_root_task());
assert!(
root_task.source().is_some(),
"with a real task list, the legacy optimistic flag must be ignored",
);
}
/// The legacy `root_task_is_optimistic` flag is ignored when restoring an
/// empty task list via `new_restored_synthesizing_on_empty`.
#[test]
fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_empty_tasks() {
let conversation_data: AgentConversationData = serde_json::from_str(
r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#,
)
.unwrap();
let conversation = AIConversation::new_restored_synthesizing_on_empty(
AIConversationId::new(),
vec![],
Some(conversation_data),
)
.expect("empty task list must synthesize an optimistic root regardless of legacy flag");
let root_task = conversation
.get_root_task()
.expect("synthesized root task should exist");
assert!(root_task.is_root_task());
assert!(root_task.source().is_none());
assert_eq!(conversation.status(), &ConversationStatus::InProgress);
}
/// Strict `new_restored` returns `NoRootTask` for an empty task list.
#[test]
fn new_restored_with_empty_task_list_returns_no_root_task_error() {
let result = AIConversation::new_restored(AIConversationId::new(), vec![], None);
assert!(
matches!(result, Err(RestoreConversationError::NoRootTask)),
"empty task list via strict new_restored must return NoRootTask; got {result:?}",
);
}
/// When multiple parentless tasks exist (e.g. a legacy orphan optimistic
/// stub alongside the real server root), `new_restored` must prefer the
/// candidate whose `messages` is non-empty. Each ordering runs in a loop to
/// surface any nondeterminism in candidate selection.
#[test]
fn test_new_restored_prefers_parentless_task_with_messages_over_empty_stub() {
let stub = api::Task {
id: "optimistic-stub-uuid".to_string(),
messages: vec![],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
};
let real = api::Task {
id: "server-root-id".to_string(),
messages: vec![user_query_message("user-msg", "request-1", "real query")],
dependencies: None,
description: String::new(),
summary: String::new(),
server_data: String::new(),
};
// Stub appears first in the vec.
for _ in 0..50 {
let conversation = AIConversation::new_restored(
AIConversationId::new(),
vec![stub.clone(), real.clone()],
None,
)
.expect("restore with stub + real parentless tasks must succeed");
let root_task = conversation
.get_root_task()
.expect("restored conversation must have a root task");
assert_eq!(
root_task.id().to_string(),
"server-root-id",
"expected the real (non-empty) parentless task to win when stub is first",
);
let source = root_task
.source()
.expect("chosen root must have api::Task source");
assert!(
!source.messages.is_empty(),
"chosen root must have non-empty messages",
);
}
// Real appears first in the vec.
for _ in 0..50 {
let conversation = AIConversation::new_restored(
AIConversationId::new(),
vec![real.clone(), stub.clone()],
None,
)
.expect("restore with real + stub parentless tasks must succeed");
let root_task = conversation
.get_root_task()
.expect("restored conversation must have a root task");
assert_eq!(
root_task.id().to_string(),
"server-root-id",
"expected the real (non-empty) parentless task to win when real is first",
);
let source = root_task
.source()
.expect("chosen root must have api::Task source");
assert!(
!source.messages.is_empty(),
"chosen root must have non-empty messages",
);
}
}
#[test]
fn cli_agent_transcript_vehicle_is_excluded_from_navigation() {
let conversation = AIConversation::new(false, true);
assert!(conversation.should_exclude_from_navigation());
}
#[test]
fn restored_conversation_defaults_unknown_persisted_autoexecute_override() {
let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true);
@@ -197,3 +716,171 @@ fn fork_artifacts_adds_file_artifacts_to_conversation() {
})
);
}
#[test]
fn waiting_for_events_display_label_is_waiting() {
assert_eq!(
format!("{}", ConversationStatus::WaitingForEvents),
"Waiting"
);
}
/// `is_done` returns true only for `Success | Error | Cancelled`;
/// `WaitingForEvents` and `Blocked` are not done because the run can still
/// resume on its own.
#[test]
fn is_done_only_includes_success_error_cancelled() {
assert!(ConversationStatus::Success.is_done());
assert!(ConversationStatus::Error.is_done());
assert!(ConversationStatus::Cancelled.is_done());
assert!(!ConversationStatus::InProgress.is_done());
assert!(!ConversationStatus::Blocked {
blocked_action: "approve".to_string()
}
.is_done());
assert!(!ConversationStatus::WaitingForEvents.is_done());
}
/// `is_waiting_for_events` is true only for the new variant.
#[test]
fn is_waiting_for_events_returns_true_only_for_waiting_for_events_variant() {
assert!(ConversationStatus::WaitingForEvents.is_waiting_for_events());
assert!(!ConversationStatus::InProgress.is_waiting_for_events());
assert!(!ConversationStatus::Success.is_waiting_for_events());
assert!(!ConversationStatus::Error.is_waiting_for_events());
assert!(!ConversationStatus::Cancelled.is_waiting_for_events());
assert!(!ConversationStatus::Blocked {
blocked_action: "approve".to_string()
}
.is_waiting_for_events());
}
/// A conversation that was yielded via `wait_for_events` at shutdown
/// restores as whatever `derive_status_from_root_task` returns (Success
/// for a cleanly-streamed last exchange). The unresolved tool call stays
/// in the transcript as an orphan; the next outbound request triggers
/// the server's existing supersede mechanism to synthesize the matching
/// `Cancel`. The waiting state itself is not durable across restart.
#[test]
fn restored_conversation_does_not_re_enter_waiting_for_events() {
let conversation_data: AgentConversationData =
serde_json::from_str(r#"{"server_conversation_token":null}"#).unwrap();
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.status(), &ConversationStatus::Success);
}
fn fetched_memory(
memory_id: &str,
content: &str,
memory_store_id: &str,
source: Option<api::message::fetched_memory::Source>,
) -> api::message::FetchedMemory {
api::message::FetchedMemory {
memory_id: memory_id.to_string(),
content: content.to_string(),
memory_store_id: memory_store_id.to_string(),
source,
}
}
fn conversation_source(conversation_id: &str) -> Option<api::message::fetched_memory::Source> {
Some(api::message::fetched_memory::Source::Conversation(
api::message::fetched_memory::Conversation {
conversation_id: conversation_id.to_string(),
},
))
}
fn restored_conversation_with_memories_per_query(
memories_per_query: Vec<Vec<api::message::FetchedMemory>>,
) -> AIConversation {
let messages = memories_per_query
.into_iter()
.enumerate()
.flat_map(|(index, memories)| {
let request_id = format!("request-{index}");
let query = api::Message {
fetched_memories: memories,
..user_query_message(&format!("user-{index}"), &request_id, "query")
};
[
query,
agent_output_message(&format!("agent-{index}"), &request_id),
]
})
.collect();
AIConversation::new_restored(
AIConversationId::new(),
vec![api::Task {
id: "root-task".to_string(),
messages,
..Default::default()
}],
None,
)
.unwrap()
}
#[test]
fn fetched_memories_is_empty_when_no_message_has_memories() {
let conversation = restored_conversation_with_memories_per_query(vec![vec![]]);
assert_eq!(conversation.fetched_memories(), vec![]);
}
#[test]
fn fetched_memories_preserves_order_across_and_within_messages() {
let conversation = restored_conversation_with_memories_per_query(vec![
vec![
fetched_memory("m1", "first", "store-1", None),
fetched_memory("m2", "second", "store-1", None),
],
vec![fetched_memory("m3", "third", "store-2", None)],
]);
let ids: Vec<String> = conversation
.fetched_memories()
.into_iter()
.map(|memory| memory.memory_id)
.collect();
assert_eq!(ids, vec!["m1", "m2", "m3"]);
}
#[test]
fn fetched_memories_dedupes_keeping_first_position_and_latest_data() {
let conversation = restored_conversation_with_memories_per_query(vec![
vec![
fetched_memory("m1", "old content", "store-1", None),
fetched_memory("m2", "other", "store-1", None),
],
vec![
fetched_memory(
"m1",
"new content",
"store-1",
conversation_source("conversation-1"),
),
fetched_memory("m1", "same memory id different store", "store-2", None),
],
]);
let memories = conversation.fetched_memories();
assert_eq!(
memories,
vec![
fetched_memory(
"m1",
"new content",
"store-1",
conversation_source("conversation-1"),
),
fetched_memory("m2", "other", "store-1", None),
fetched_memory("m1", "same memory id different store", "store-2", None),
]
);
}
+70 -4
View File
@@ -9,11 +9,10 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use warp_multi_agent_api as api;
use api::message::tool_call::Tool;
use api::message::tool_call_result::Result as ToolCallResultType;
use api::message::Message;
use warp_multi_agent_api as api;
use super::task::helper::{SubagentExt, ToolExt};
@@ -243,7 +242,8 @@ fn write_task_messages(
| Message::SystemQuery(_)
| Message::CodeReview(_)
| Message::ServerEvent(_)
| Message::InvokeSkill(_) => {}
| Message::InvokeSkill(_)
| Message::OrchestrationConfigSnapshot(_) => {}
}
}
Ok(())
@@ -507,6 +507,19 @@ fn write_tool_call_args(out: &mut String, tool: &Tool) {
out.push_str(&format!(" - deleted: {}\n", df.file_path));
}
}
Tool::RunAgents(o) => {
out.push_str(&format!(
"summary: \"{}\"\n",
escape_yaml_string(&o.summary)
));
out.push_str("agents:\n");
for cfg in &o.agent_run_configs {
out.push_str(&format!(
" - name: \"{}\"\n",
escape_yaml_string(&cfg.name)
));
}
}
// No additional args worth serializing.
Tool::ReadShellCommandOutput(_)
| Tool::UseComputer(_)
@@ -519,13 +532,65 @@ fn write_tool_call_args(out: &mut String, tool: &Tool) {
| Tool::InitProject(_)
| Tool::Server(_)
| Tool::Subagent(_)
| Tool::TransferShellCommandControlToUser(_) => {}
| Tool::TransferShellCommandControlToUser(_)
| Tool::WaitForEvents(_) => {}
}
}
/// Writes content from structured tool call results.
fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) {
match result {
ToolCallResultType::RunAgentsResult(r) => match &r.outcome {
Some(api::run_agents_result::Outcome::Launched(launched)) => {
out.push_str("status: launched\n");
out.push_str(&format!("agent_count: {}\n", launched.agents.len()));
if !launched.agents.is_empty() {
out.push_str("agents:\n");
for agent in &launched.agents {
out.push_str(&format!(
" - name: \"{}\"\n",
escape_yaml_string(&agent.name)
));
match &agent.result {
Some(api::run_agents_result::agent_outcome::Result::Launched(
launched,
)) => {
out.push_str(" status: launched\n");
out.push_str(&format!(" agent_id: {}\n", launched.agent_id));
}
Some(api::run_agents_result::agent_outcome::Result::Failed(failed)) => {
out.push_str(" status: failed\n");
out.push_str(&format!(
" error: \"{}\"\n",
escape_yaml_string(&failed.error)
));
}
None => {
out.push_str(" status: unknown\n");
}
}
}
out.push_str(
"next_step: \"Use send_message_to_agent with the existing agent_id instead of running agents again.\"\n",
);
}
}
Some(api::run_agents_result::Outcome::Denied(denied)) => {
out.push_str("status: launch_denied\n");
out.push_str(&format!(
"reason: \"{}\"\n",
escape_yaml_string(&denied.reason)
));
}
Some(api::run_agents_result::Outcome::Failure(failure)) => {
out.push_str("status: failure\n");
out.push_str(&format!(
"error: \"{}\"\n",
escape_yaml_string(&failure.error)
));
}
None => {}
},
ToolCallResultType::StartAgentV2(r) => match &r.result {
Some(api::start_agent_v2_result::Result::Success(s)) => {
out.push_str(&format!("agent_id: {}\n", s.agent_id));
@@ -535,6 +600,7 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType)
}
None => {}
},
ToolCallResultType::WaitForEvents(_) => {}
ToolCallResultType::RunShellCommand(r) => {
if let Some(res) = &r.result {
use api::run_shell_command_result::Result;
+68 -2
View File
@@ -3,12 +3,11 @@ use std::path::Path;
use warp_multi_agent_api as api;
use super::{base_dir, materialize_tasks_to_yaml};
use crate::test_util::ai_agent_tasks::{
create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message,
};
use super::{base_dir, materialize_tasks_to_yaml};
/// Lists filenames (not full paths) in a directory, sorted.
fn list_dir_sorted(dir: &Path) -> Vec<String> {
let mut entries: Vec<String> = fs::read_dir(dir)
@@ -22,6 +21,7 @@ fn list_dir_sorted(dir: &Path) -> Vec<String> {
fn make_user_query_message(id: &str, task_id: &str, query: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -45,6 +45,7 @@ fn make_tool_call_message(
tool: api::message::tool_call::Tool,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -65,6 +66,7 @@ fn make_tool_call_result_message(
result: api::message::tool_call_result::Result,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -133,6 +135,70 @@ fn mixed_message_types_produce_sequentially_indexed_files() {
cleanup_dir(&dir);
}
#[test]
fn run_agents_result_serializes_agent_ids() {
let task_id = "root";
let tasks = vec![create_api_task(
task_id,
vec![make_tool_call_result_message(
"m1",
task_id,
"tc_run_agents",
api::message::tool_call_result::Result::RunAgentsResult(api::RunAgentsResult {
outcome: Some(api::run_agents_result::Outcome::Launched(
api::run_agents_result::Launched {
resolved_model_id: "auto".to_string(),
resolved_harness: Some(api::Harness {
variant: Some(api::harness::Variant::Oz(api::harness::Oz {})),
}),
resolved_execution_mode: Some(
api::run_agents_result::launched::ResolvedExecutionMode::Local(
api::run_agents::Local {},
),
),
agents: vec![
api::run_agents_result::AgentOutcome {
name: "child".to_string(),
result: Some(
api::run_agents_result::agent_outcome::Result::Launched(
api::run_agents_result::LaunchedAgent {
agent_id: "agent-123".to_string(),
},
),
),
},
api::run_agents_result::AgentOutcome {
name: "other".to_string(),
result: Some(
api::run_agents_result::agent_outcome::Result::Failed(
api::run_agents_result::FailedAgent {
error: "failed to start".to_string(),
},
),
),
},
],
},
)),
}),
)],
)];
let dir = materialize_tasks_to_yaml(&tasks).unwrap();
let files = list_dir_sorted(Path::new(&dir));
let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap();
assert!(content.contains("status: launched"));
assert!(content.contains("agent_count: 2"));
assert!(content.contains("name: \"child\""));
assert!(content.contains("agent_id: agent-123"));
assert!(content.contains("name: \"other\""));
assert!(content.contains("error: \"failed to start\""));
assert!(content.contains("Use send_message_to_agent with the existing agent_id"));
cleanup_dir(&dir);
}
#[test]
fn subagent_file_and_subdirectory_share_same_index() {
let root_id = "root";
+4 -2
View File
@@ -1,6 +1,8 @@
use galaxy_core::ui::{appearance::Appearance, theme::AnsiColorIdentifier};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use crate::ui_components::{blended_colors, icons::Icon};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
pub fn todo_list_icon(appearance: &Appearance) -> galaxyui::elements::Icon {
galaxyui::elements::Icon::new(
+5 -1
View File
@@ -1,11 +1,13 @@
use std::collections::HashMap;
use super::*;
use warp_multi_agent_api as api;
use super::*;
// Helper function to create a basic message
fn create_message(id: &str, task_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
@@ -22,6 +24,7 @@ fn create_message(id: &str, task_id: &str) -> api::Message {
fn create_subagent_tool_call_message(id: &str, task_id: &str, subtask_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
@@ -44,6 +47,7 @@ fn create_subagent_tool_call_message(id: &str, task_id: &str, subtask_id: &str)
// Helper function to create a tool call result message.
fn create_tool_call_result_message(id: &str, task_id: &str, tool_call_id: &str) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: "server_data".to_string(),
+397 -96
View File
@@ -12,50 +12,48 @@ mod task_store;
pub(super) mod telemetry;
pub(super) mod util;
// Re-export types that were moved to the ai crate.
pub use ai::agent::{action::*, action_result::*, AIAgentCitation, FileLocations};
use galaxy_core::features::FeatureFlag;
#[cfg(test)]
mod suggestion_test;
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::block::view_impl::output::are_all_text_sections_empty;
use crate::ai::skills::SkillDescriptor;
use crate::code::editor_management::CodeSource;
use crate::code_review::comments::{
AttachedReviewComment as CodeReviewComment, ReviewCommentBatch,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::server_api::AIApiError;
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use task::TaskId;
pub use telemetry::AIIdentifiers;
use galaxy_editor::render::model::LineCount;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::ops::{AddAssign, Deref, DerefMut, Range};
use std::sync::Arc;
use std::time::Duration;
// Re-export types that were moved to the ai crate.
pub use ai::agent::action::*;
pub use ai::agent::action_result::*;
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
pub use ai::agent::{AIAgentCitation, FileLocations};
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::ParticipantId;
use task::TaskId;
pub use telemetry::AIIdentifiers;
use uuid::Uuid;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use warp_editor::render::model::LineCount;
use warp_multi_agent_api::{diff_hunk as diff_hunk_api, AgentEvent, AgentType};
pub use self::api::{MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError};
use super::llms::LLMId;
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::block::view_impl::output::are_all_text_sections_empty;
use crate::ai::skills::SkillDescriptor;
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::code::editor_management::CodeSource;
use crate::code_review::comments::{
AttachedReviewComment as CodeReviewComment, ReviewCommentBatch,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::server_api::{AIApiError, DeserializationError};
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType;
use crate::terminal::view::block_onboarding::onboarding_agentic_suggestions_block::OnboardingChipType;
use crate::TelemetryEvent;
use derivative::Derivative;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::ParticipantId;
use super::llms::LLMId;
/// A server supplied ID for a specific AI generated output.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
@@ -84,6 +82,8 @@ impl ServerOutputId {
pub enum CancellationReason {
/// The user explicitly cancelled without providing a follow-up.
ManuallyCancelled,
/// Warp automatically cancelled the local run so it could continue in Cloud Mode.
AutomaticCloudHandoff,
/// The user submitted a follow-up query during streaming which implicitly cancelled the current one.
FollowUpSubmitted {
@@ -99,22 +99,65 @@ pub enum CancellationReason {
// The user deleted the conversation while it was in progress.
Deleted,
/// The long-running command completed while the agent was still streaming.
/// The long-running command completed while the agent was still streaming a response started via inline agent view.
/// This should be treated as a successful completion, not a cancellation.
OptimisticCLISubagentCompletion,
/// Note this is only used for inline agent view (user starting an agent to monitor an already running command),
/// not when CLI subagent monitors a requested command.
CommandFinishedDuringInlineAgentView,
/// The user manually took control of a long-running command away from the agent.
/// The agent conversation is still in progress — it will resume after the command
/// finishes or once the user hands control back. The stream is cancelled only to
/// stop the CLI subagent monitoring loop, not to end the conversation.
CLISubagentUserTakeover,
/// An agent-issued command caused the shell process to exit (e.g. it ran
/// `exit`, or ran a failing command after enabling `set -e`). The in-flight
/// stream/actions are cancelled to stop work, but the conversation is
/// finalized as a terminal `Error` (with a shell-exit message) by the
/// controller rather than reported as a user cancellation.
AgentExitedShell,
}
/// How a [`CancellationReason`] maps to the conversation's resulting status.
/// This is the single source of truth consumed by the stream- and
/// action-cancellation machinery; see [`CancellationReason::conversation_outcome`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancellationOutcome {
/// Leave the conversation `InProgress`; it will continue on its own (a
/// follow-up request or a resumed long-running command) without further user
/// input.
KeepInProgress,
/// Finalize the conversation as a successful completion (`Success`).
Succeeded,
/// Finalize the conversation as a user cancellation (`Cancelled`).
Cancelled,
/// Terminal, but a dedicated path (not the cancellation machinery) writes the
/// status — the cancellation is only a stop signal and must not stamp a status.
/// Currently used for shell exit, which is finalized as `Error` by
/// `fail_conversation_due_to_shell_exit`. Unlike `KeepInProgress`, the
/// conversation is ending; only the status write is suppressed.
FinalizedExternally,
}
impl Display for CancellationReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CancellationReason::ManuallyCancelled => write!(f, "manual cancellation"),
CancellationReason::AutomaticCloudHandoff => write!(f, "automatic cloud handoff"),
CancellationReason::FollowUpSubmitted { .. } => write!(f, "follow-up submission"),
CancellationReason::UserCommandExecuted => write!(f, "user command execution"),
CancellationReason::Reverted => write!(f, "revert"),
CancellationReason::Deleted => write!(f, "deleted"),
CancellationReason::OptimisticCLISubagentCompletion => {
CancellationReason::CommandFinishedDuringInlineAgentView => {
write!(f, "LRC command completed")
}
CancellationReason::CLISubagentUserTakeover => {
write!(f, "CLI subagent user takeover")
}
CancellationReason::AgentExitedShell => {
write!(f, "agent command exited the shell")
}
}
}
}
@@ -139,8 +182,36 @@ impl CancellationReason {
matches!(self, CancellationReason::Reverted)
}
pub fn is_lrc_command_completed(&self) -> bool {
matches!(self, CancellationReason::OptimisticCLISubagentCompletion)
/// How a cancellation reason maps to the
/// conversation's resulting status. Every site that finalizes a cancelled
/// stream or action consults this instead of re-deriving the disposition,
/// so the reason -> status mapping lives in one exhaustive place.
/// Note that sometimes the action result is treated as authoritative for determining
/// conversation status even when there is a cancellation reason (taking priority over this)
pub fn conversation_outcome(&self) -> CancellationOutcome {
match self {
// The conversation continues without further user input (a follow-up
// request or a resumed long-running command drives it forward), so
// its status must stay InProgress.
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
}
| CancellationReason::CLISubagentUserTakeover => CancellationOutcome::KeepInProgress,
// A long-running command finishing (optimistically) or a revert are
// successful completions rather than cancellations.
CancellationReason::CommandFinishedDuringInlineAgentView
| CancellationReason::Reverted => CancellationOutcome::Succeeded,
// The shell died under the agent; a dedicated path finalizes this as a
// terminal `Error`, so the cancellation machinery must not stamp a status.
CancellationReason::AgentExitedShell => CancellationOutcome::FinalizedExternally,
CancellationReason::ManuallyCancelled
| CancellationReason::AutomaticCloudHandoff
| CancellationReason::UserCommandExecuted
| CancellationReason::Deleted
| CancellationReason::FollowUpSubmitted {
is_for_same_conversation: false,
} => CancellationOutcome::Cancelled,
}
}
}
@@ -401,6 +472,9 @@ pub struct OutputModelInfo {
pub model_id: LLMId,
pub display_name: String,
pub is_fallback: bool,
/// When the provider-side prompt cache for this request is expected to
/// expire. `None` means unknown / no cache-expiry info.
pub prompt_cache_expires_at: Option<DateTime<Local>>,
}
impl Display for AIAgentOutput {
@@ -615,9 +689,11 @@ impl AIAgentOutput {
}
/// Represents user visible errors.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[derive(Clone, Debug)]
pub enum RenderableAIError {
QuotaLimit,
QuotaLimit {
user_display_message: Option<String>,
},
ServerOverloaded,
InternalWarpError,
ContextWindowExceeded(String),
@@ -628,16 +704,59 @@ pub enum RenderableAIError {
AwsBedrockCredentialsExpiredOrInvalid {
model_name: String,
},
/// A transient network failure (lost connection or truncated response stream). Carries its
/// own complete user-facing copy; `kind` preserves the structured cause (including the raw
/// API error) so user reports can disambiguate the different causes behind the shared message.
TransientNetworkError {
kind: TransientNetworkErrorKind,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
},
Other {
error_message: String,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
/// True when the error originates from a user-side issue (e.g., model not allowed,
/// blocked due to fraud, plan restriction). Maps the task to FAILED state instead of ERROR.
is_user_error: bool,
},
/// An agent-issued command caused the shell process to exit, so the run
/// cannot continue. Surfaced as a terminal failure (FAILED) rather than a
/// user cancellation.
AgentExitedShell,
}
impl RenderableAIError {
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
"Warp lost connection while receiving the agent response. This is usually temporary.";
/// User-facing message shown when an agent-issued command exits the shell.
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str =
"The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
/// Creates a transient network error. `kind` is the structured cause (including the raw API
/// error where one exists), preserved so user reports can disambiguate the different causes
/// behind the shared user-facing copy.
pub fn transient_network_error(
will_attempt_resume: bool,
waiting_for_network: bool,
kind: TransientNetworkErrorKind,
) -> Self {
Self::TransientNetworkError {
kind,
will_attempt_resume,
waiting_for_network,
}
}
fn is_transient_network_transport_error(error: &reqwest::Error) -> bool {
// If reqwest has an HTTP status, the server responded. Preserve the existing generic
// rendering for those failures rather than calling them lost connections.
error.status().is_none()
}
pub fn is_invalid_api_key(&self) -> bool {
matches!(self, Self::InvalidApiKey { .. })
}
@@ -653,20 +772,99 @@ impl RenderableAIError {
Self::Other {
will_attempt_resume: true,
..
} | Self::TransientNetworkError {
will_attempt_resume: true,
..
}
)
}
/// Whether the failed-output UI should be suppressed while an automatic resume is in
/// flight. Release builds stay quiet so transient blips that recover on their own
/// don't surface an alarming error; dogfood builds (Local/Dev) keep the old, more
/// aggressive behavior so developers still see every transport failure.
pub fn should_suppress_during_recovery(&self) -> bool {
self.will_attempt_resume() && !ChannelState::channel().is_dogfood()
}
/// Constructs a generic [`RenderableAIError::Other`] from a message.
/// `is_user_error` selects the task classification (true → FAILED, false →
/// ERROR). The resume/network flags are false: this is for terminal,
/// out-of-band errors that are not auto-resumed.
pub fn other(error_message: impl Into<String>, is_user_error: bool) -> Self {
Self::Other {
error_message: error_message.into(),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
impl From<&AIApiError> for RenderableAIError {
fn from(value: &AIApiError) -> Self {
match value {
AIApiError::QuotaLimit => Self::QuotaLimit,
/// The cause behind a [`RenderableAIError::TransientNetworkError`]. Kept structured (rather than
/// collapsed to a free-form string) so user reports preserve the raw error; rendered to text only
/// at display time.
#[derive(Clone, Debug, thiserror::Error)]
pub enum TransientNetworkErrorKind {
/// A lost connection or truncated response stream — the raw underlying API error. Rendered via
/// `Debug` so reports preserve the full structured error rather than its terse `Display`.
#[error("{0:?}")]
Api(Arc<AIApiError>),
/// The response stream completed with an unfinished exchange and no error event.
#[error("stream completed with an unfinished exchange and no error event")]
UnfinishedExchange,
/// The conversation was left in a transient-error state but the last exchange carried no
/// structured error to surface.
#[error("no structured error on the last exchange")]
MissingExchangeError,
}
impl From<&Arc<AIApiError>> for RenderableAIError {
fn from(value: &Arc<AIApiError>) -> Self {
// Non-retryable 4xx errors (403 fraud block, 400 model/plan restriction, etc.)
// are user-originating — map them to a user error so the task reaches FAILED
// state rather than ERROR state.
let is_user_error = !value.is_recoverable();
match value.as_ref() {
AIApiError::QuotaLimit {
user_display_message,
} => Self::QuotaLimit {
user_display_message: user_display_message.clone(),
},
AIApiError::ServerOverloaded => Self::ServerOverloaded,
_ => Self::Other {
AIApiError::Transport(error)
| AIApiError::Deserialization(DeserializationError::Transport(error)) => {
// A transport error with no HTTP status is a lost-connection failure; one that
// carries a status means the server responded, so it gets generic rendering.
if Self::is_transient_network_transport_error(error) {
Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
)
} else {
Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
AIApiError::UnexpectedEof => Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
),
AIApiError::Deserialization(DeserializationError::Json(_))
| AIApiError::NoContextFound
| AIApiError::ErrorStatus(_, _)
| AIApiError::Other(_)
| AIApiError::Stream { .. } => Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
},
}
}
@@ -675,7 +873,15 @@ impl From<&AIApiError> for RenderableAIError {
impl Display for RenderableAIError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::QuotaLimit => write!(f, "Quota limit reached."),
Self::QuotaLimit {
user_display_message,
} => {
if let Some(message) = user_display_message {
write!(f, "{message}")
} else {
write!(f, "Quota limit reached.")
}
}
Self::ServerOverloaded => {
write!(f, "Warp is currently overloaded. Please try again later.")
}
@@ -692,7 +898,15 @@ impl Display for RenderableAIError {
"AWS Bedrock credentials expired or invalid for {model_name}"
)
}
Self::TransientNetworkError { kind, .. } => {
write!(
f,
"{}\n\nDebug info: {kind}",
Self::TRANSIENT_NETWORK_ERROR_MESSAGE
)
}
Self::Other { error_message, .. } => write!(f, "{error_message}"),
Self::AgentExitedShell => write!(f, "{}", Self::AGENT_EXITED_SHELL_MESSAGE),
}
}
}
@@ -717,13 +931,19 @@ impl ProgrammingLanguage {
#[cfg_attr(target_family = "wasm", allow(unused))]
pub fn to_extension(&self) -> Option<&str> {
match self {
// The arms below cover both canonical language names emitted by the agent (e.g.
// "rust", "kotlin") and common markdown code-fence aliases (e.g. "rs", "kt") to keep
// syntax highlighting working when the model uses either. The set of recognized
// languages here is kept in sync with `SUPPORTED_LANGUAGES` in the `languages` crate.
Self::Other(language) => match language.to_lowercase().as_str() {
"rust" => Some("rs"),
"go" => Some("go"),
"python" => Some("py"),
"javascript" => Some("js"),
"typescript" => Some("ts"),
"yaml" => Some("yaml"),
"rust" | "rs" => Some("rs"),
"go" | "golang" => Some("go"),
"python" | "py" => Some("py"),
"javascript" | "js" => Some("js"),
"typescript" | "ts" => Some("ts"),
"jsx" => Some("jsx"),
"tsx" => Some("tsx"),
"yaml" | "yml" => Some("yaml"),
"cpp" | "c++" => Some("cpp"),
"java" => Some("java"),
"groovy" => Some("java"),
@@ -733,17 +953,23 @@ impl ProgrammingLanguage {
"css" => Some("css"),
"c" => Some("c"),
"json" => Some("json"),
"hcl" => Some("hcl"),
"jq" => Some("jq"),
"hcl" | "terraform" | "tf" => Some("hcl"),
"lua" => Some("lua"),
"ruby" => Some("rb"),
"ruby" | "rb" => Some("rb"),
"php" => Some("php"),
"toml" => Some("toml"),
"swift" => Some("swift"),
"kotlin" => Some("kt"),
"kotlin" | "kt" => Some("kt"),
"powershell" => Some("ps1"),
"elixir" => Some("exs"),
"scala" => Some("scala"),
"sql" => Some("sql"),
"objective-c" | "objc" => Some("m"),
"starlark" => Some("bzl"),
"xml" => Some("xml"),
"vue" => Some("vue"),
"dockerfile" | "docker" | "containerfile" => Some("dockerfile"),
_ => None,
},
Self::Shell(ShellType::PowerShell) => Some("ps1"),
@@ -1513,9 +1739,12 @@ pub enum SubagentType {
Summarization,
ConversationSearch {
query: Option<String>,
/// The ID of the conversation being searched. None when searching the
/// current conversation.
/// Search targets are mutually exclusive; at most one of `conversation_id` or
/// `agent_run_id` should be populated for a single conversation search subagent.
/// The ID of the conversation being searched.
conversation_id: Option<String>,
/// The ID of the agent run being searched.
agent_run_id: Option<String>,
},
WarpDocumentationSearch,
Unknown,
@@ -2010,6 +2239,30 @@ pub enum AIAgentContext {
branch: Option<String>,
},
/// Information about the git repository in the current working directory.
Repository {
/// The repository name (e.g. "warp-internal").
name: String,
/// The repository owner/organization (e.g. "warpdotdev"), if determinable from the remote URL.
owner: Option<String>,
},
/// Information about the GitHub pull request associated with the current branch.
PullRequest {
/// The pull request number.
#[serde(default, deserialize_with = "deserialize_pull_request_number")]
number: i32,
/// The pull request state (for example, `OPEN`, `MERGED`, or `CLOSED`).
#[serde(default)]
state: String,
/// Whether the pull request is marked as draft.
#[serde(default)]
draft: bool,
/// The pull request's base branch.
#[serde(default)]
base_branch: String,
},
/// List of available skills is provided to the agent during initialization
/// or when updated.
Skills {
@@ -2020,6 +2273,37 @@ pub enum AIAgentContext {
Block(Box<BlockContext>),
}
fn deserialize_pull_request_number<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(0),
serde_json::Value::String(s) => {
if !s.chars().all(|c| c.is_ascii_digit()) {
return Ok(0);
}
Ok(s.parse()
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
serde_json::Value::Number(n) => {
let Some(number) = n.as_i64() else {
return Ok(0);
};
Ok(i32::try_from(number)
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
value => Err(serde::de::Error::custom(format!(
"expected string or number for pull request number, got {value}"
))),
}
}
#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct ImageContext {
/// Base64-encoded image data.
@@ -2229,16 +2513,12 @@ pub enum StaticQueryType {
Code,
Deploy,
SomethingElse,
CustomOnboardingRequest,
EvaluationSuite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum EntrypointType {
Onboarding {
chip_type: OnboardingChipType,
},
PromptSuggestion {
is_static: bool,
is_coding: bool,
@@ -2327,6 +2607,31 @@ pub enum UserQueryMode {
Orchestrate,
}
pub fn extract_user_query_mode(query: String) -> (String, UserQueryMode) {
if let Some(query) = commands::strip_command_prefix(&query, commands::PLAN_NAME) {
(query, UserQueryMode::Plan)
} else if let Some(query) = commands::strip_command_prefix(&query, commands::ORCHESTRATE_NAME) {
(query, UserQueryMode::Orchestrate)
} else {
(query, UserQueryMode::Normal)
}
}
/// Reconstructs the display form of a user query that has been stripped via
/// [`extract_user_query_mode`], by re-prepending the slash-command prefix
/// associated with [`UserQueryMode`].
///
/// This is the inverse of [`extract_user_query_mode`] and the canonical way
/// for UI to render a stored `(mode, query)` pair so the displayed prompt
/// always matches what the user originally submitted.
pub fn display_user_query_with_mode(mode: UserQueryMode, query: &str) -> String {
match mode {
UserQueryMode::Normal => query.to_owned(),
UserQueryMode::Plan => format!("{} {query}", commands::PLAN.name),
UserQueryMode::Orchestrate => format!("{} {query}", commands::ORCHESTRATE.name),
}
}
// TODO(zachbai): Refactor this to consolidate with `LongRunningCommandSnapshot` and `Snapshot`
// variants of `ReadShellCommandOutputResult` and `WriteToLongRunningShellCommandResult`.
#[derive(Clone, Debug, PartialEq)]
@@ -2422,6 +2727,7 @@ pub enum AIAgentInput {
SummarizeConversation {
prompt: Option<String>,
context: Arc<[AIAgentContext]>,
},
/// Invoke a skill. The skill content is passed as instructions to the agent.
@@ -2468,6 +2774,15 @@ pub enum AIAgentInput {
suggestion: PassiveSuggestionResultType,
context: Arc<[AIAgentContext]>,
},
/// Piggybacked orchestration config update from the plan card.
/// Sent on the next outbound request after the user edits the
/// config block or toggles approval.
OrchestrationConfigUpdate {
plan_id: String,
config: OrchestrationConfig,
status: OrchestrationConfigStatus,
},
}
/// Data for a single message received by an agent from another agent.
@@ -2525,7 +2840,7 @@ impl Display for AIAgentInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.user_query().unwrap_or_default())
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
}
Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}")
@@ -2561,24 +2876,23 @@ impl Display for AIAgentInput {
write!(f, "EventsFromAgents({} events)", events.len())
}
Self::PassiveSuggestionResult { .. } => write!(f, "PassiveSuggestionResult"),
Self::OrchestrationConfigUpdate { .. } => write!(f, "OrchestrationConfigUpdate"),
}
}
}
impl AIAgentInput {
pub fn user_query(&self) -> Option<String> {
/// Display text for any input that surfaces a prompt-like query in the UI
/// (typed queries, slash commands, skill invocations, etc.). Unlike
/// [`Self::is_user_query`], which strictly matches the `UserQuery` variant,
/// this returns `Some` for several input variants.
pub fn display_query(&self) -> Option<String> {
match self {
Self::UserQuery {
query,
user_query_mode,
..
} => match user_query_mode {
UserQueryMode::Plan => Some(format!("{} {query}", commands::PLAN.name)),
UserQueryMode::Orchestrate => {
Some(format!("{} {query}", commands::ORCHESTRATE.name))
}
UserQueryMode::Normal => Some(query.clone()),
},
} => Some(display_user_query_with_mode(*user_query_mode, query)),
Self::CreateNewProject { query, .. } => Some(query.clone()),
Self::CloneRepository {
clone_repo_url: url,
@@ -2624,7 +2938,8 @@ impl AIAgentInput {
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. } => None,
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2634,7 +2949,7 @@ impl AIAgentInput {
&self,
initial_conversation_query: Option<&String>,
) -> Option<String> {
let mut query = self.user_query()?;
let mut query = self.display_query()?;
if self
.user_query_mode()
.is_none_or(|mode| matches!(mode, UserQueryMode::Normal))
@@ -2719,9 +3034,10 @@ impl AIAgentInput {
| Self::InvokeSkill { context, .. }
| Self::StartFromAmbientRunPrompt { context, .. }
| Self::PassiveSuggestionResult { context, .. } => Some(context),
Self::SummarizeConversation { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. } => None,
Self::SummarizeConversation { context, .. } => Some(context),
Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2752,7 +3068,8 @@ impl AIAgentInput {
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. } => None,
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
@@ -2859,7 +3176,7 @@ impl AIAgentExchange {
let user_queries: Vec<String> = self
.input
.iter()
.filter_map(|input| input.user_query())
.filter_map(|input| input.display_query())
.collect();
user_queries.join("\n")
}
@@ -2909,7 +3226,9 @@ impl AIAgentExchange {
}
pub fn has_user_query(&self) -> bool {
self.input.iter().any(|input| input.user_query().is_some())
self.input
.iter()
.any(|input| input.display_query().is_some())
}
pub fn has_accepted_file_edit(&self) -> bool {
@@ -2970,25 +3289,7 @@ pub struct RequestMetadata {
pub is_auto_resume_after_error: bool,
}
/// A globally unique ID for a suggested objects.
///
/// This is used for telemetry purposes to track and connect both:
/// - Suggested objects generated by the AI agent
/// - The corresponding objects stored in the cloud (if the suggestion was accepted)
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SuggestedLoggingId(String);
impl Display for SuggestedLoggingId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for SuggestedLoggingId {
fn from(value: String) -> Self {
Self(value)
}
}
pub use cloud_object_models::SuggestedLoggingId;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SuggestedRule {
@@ -3034,5 +3335,5 @@ impl Suggestions {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,15 +1,18 @@
use std::ops::Range;
use std::sync::Arc;
use anyhow::anyhow;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{
AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentTextSection,
AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AnyFileContent,
FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind,
};
use crate::server::server_api::AIApiError;
use crate::terminal::shell::ShellType;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
Some(FileContentLineRange {
@@ -46,6 +49,80 @@ fn formatted_text_wrapper_preserves_content() {
assert_eq!(ft.lines.len(), 2);
}
fn deserialize_pull_request_number_from_json(number_json: &str) -> serde_json::Result<i32> {
let context = serde_json::from_str::<AIAgentContext>(&format!(
r#"{{"PullRequest":{{"number":{number_json}}}}}"#
))?;
match context {
AIAgentContext::PullRequest { number, .. } => Ok(number),
other => panic!("expected pull request context, got {other:?}"),
}
}
#[test]
fn pull_request_number_deserializer_accepts_positive_number_and_string() {
assert_eq!(deserialize_pull_request_number_from_json("42").unwrap(), 42);
assert_eq!(
deserialize_pull_request_number_from_json(r#""42""#).unwrap(),
42
);
}
#[test]
fn pull_request_number_deserializer_defaults_invalid_numbers() {
for number_json in ["null", "0", "-1", "1.5", "2147483648", r#""""#, r#""abc""#] {
assert_eq!(
deserialize_pull_request_number_from_json(number_json).unwrap(),
0,
"expected {number_json} to deserialize to default pull request number",
);
}
}
#[test]
fn pull_request_number_deserializer_rejects_unsupported_json_types() {
for number_json in ["true", "[]", "{}"] {
assert!(
deserialize_pull_request_number_from_json(number_json).is_err(),
"expected {number_json} to fail deserialization",
);
}
}
#[test]
fn transient_network_error_includes_user_facing_message_and_debug_details() {
let error = RenderableAIError::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(Arc::new(AIApiError::Other(anyhow!("connection reset")))),
);
let rendered = error.to_string();
assert!(
rendered.starts_with(
"Warp lost connection while receiving the agent response. This is usually temporary.\n\nDebug info: "
),
"unexpected rendering: {rendered}"
);
// The raw underlying API error must survive into the debug section.
assert!(
rendered.contains("connection reset"),
"raw error detail should surface in debug info: {rendered}"
);
assert!(!error.will_attempt_resume());
}
#[test]
fn transient_network_error_reports_pending_resume() {
let error = RenderableAIError::transient_network_error(
true,
false,
TransientNetworkErrorKind::Api(Arc::new(AIApiError::Other(anyhow!("connection reset")))),
);
assert!(error.will_attempt_resume());
}
#[test]
fn test_convert_files() {
let a = FileContext::new(
@@ -152,6 +229,86 @@ fn test_programming_language_from_string() {
);
}
#[test]
fn test_programming_language_to_extension() {
// Each entry is (markdown language token, expected extension). The expected extension
// must resolve back to a recognized language via `languages::language_by_filename` so that
// syntax highlighting is applied to the AI block.
let cases: &[(&str, &str)] = &[
// Canonical names.
("rust", "rs"),
("go", "go"),
("python", "py"),
("javascript", "js"),
("typescript", "ts"),
("yaml", "yaml"),
("cpp", "cpp"),
("java", "java"),
("c#", "cs"),
("csharp", "cs"),
("html", "html"),
("css", "css"),
("c", "c"),
("json", "json"),
("hcl", "hcl"),
("lua", "lua"),
("ruby", "rb"),
("php", "php"),
("toml", "toml"),
("swift", "swift"),
("kotlin", "kt"),
("powershell", "ps1"),
("elixir", "exs"),
("scala", "scala"),
("sql", "sql"),
// Languages newly covered by this fix — previously fell through to None and rendered
// without syntax highlighting in AI blocks even though the `languages` crate supports them.
("jsx", "jsx"),
("tsx", "tsx"),
("xml", "xml"),
("vue", "vue"),
("dockerfile", "dockerfile"),
("starlark", "bzl"),
("objective-c", "m"),
("objc", "m"),
// Common markdown code-fence aliases.
("rs", "rs"),
("golang", "go"),
("py", "py"),
("js", "js"),
("ts", "ts"),
("yml", "yaml"),
("c++", "cpp"),
("rb", "rb"),
("kt", "kt"),
("terraform", "hcl"),
("tf", "hcl"),
("docker", "dockerfile"),
("containerfile", "dockerfile"),
];
for (token, expected_extension) in cases {
let language = ProgrammingLanguage::from((*token).to_string());
assert_eq!(
language.to_extension(),
Some(*expected_extension),
"expected to_extension({token:?}) to be Some({expected_extension:?})",
);
}
// PowerShell remains the only Shell variant whose extension is exposed; this preserves
// existing behavior for the other Shell variants which are intentionally not extended here.
assert_eq!(
ProgrammingLanguage::Shell(ShellType::PowerShell).to_extension(),
Some("ps1"),
);
// Unrecognized tokens still return None.
assert_eq!(
ProgrammingLanguage::Other("definitely-not-a-language".to_string()).to_extension(),
None,
);
}
#[test]
fn format_for_copy_preserves_visual_markdown_sections() {
let output = AIAgentOutput {
@@ -189,3 +346,6 @@ fn format_for_copy_preserves_visual_markdown_sections() {
"Intro\n![Diagram](./diagram.png)\n```mermaid\ngraph TD\nA --> B\n```"
);
}
#[path = "suggestions_tests.rs"]
mod suggestions;
+13 -6
View File
@@ -1,15 +1,14 @@
use std::sync::Arc;
use super::super::blocklist::block::secret_redaction::{
find_secrets_in_text, SECRET_REDACTION_REPLACEMENT_CHARACTER,
};
use crate::ai::agent::{
AIAgentActionResultType, AIAgentAttachment, AIAgentContext, AIAgentInput, AnyFileContent,
AskUserQuestionAnswerItem, AskUserQuestionResult, BlockContext, PassiveSuggestionResultType,
PassiveSuggestionTrigger, RequestCommandOutputResult, TransferShellCommandControlToUserResult,
};
use super::super::blocklist::block::secret_redaction::{
find_secrets_in_text, SECRET_REDACTION_REPLACEMENT_CHARACTER,
};
/// Redact all detected secrets in-place within the given string.
pub(crate) fn redact_secrets(input: &mut String) {
let mut secrets: Vec<_> = find_secrets_in_text(input)
@@ -53,10 +52,11 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
| AIAgentInput::StartFromAmbientRunPrompt { context, .. } => {
redact_context(Arc::make_mut(context));
}
AIAgentInput::SummarizeConversation { prompt } => {
AIAgentInput::SummarizeConversation { prompt, context } => {
if let Some(p) = prompt {
redact_secrets(p);
}
redact_context(Arc::make_mut(context));
}
AIAgentInput::CreateEnvironment { context, .. } => {
redact_context(Arc::make_mut(context));
@@ -105,7 +105,8 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
}
// No user-provided text to redact in inter-agent relay inputs.
AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. } => {}
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => {}
AIAgentInput::ActionResult { result, context } => {
redact_context(Arc::make_mut(context));
match &mut result.result {
@@ -260,6 +261,10 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
AIAgentActionResultType::AskUserQuestion(result) => {
redact_ask_user_question_result(result);
}
// Orchestrate results contain agent IDs / canonical error
// strings only; no user-provided text to redact.
AIAgentActionResultType::RunAgents(_)
| AIAgentActionResultType::WaitForEvents(_) => {}
}
}
AIAgentInput::FetchReviewComments { repo_path, context } => {
@@ -344,6 +349,8 @@ fn redact_context(context: &mut [AIAgentContext]) {
| AIAgentContext::Codebase { .. }
| AIAgentContext::ProjectRules { .. }
| AIAgentContext::Git { .. }
| AIAgentContext::Repository { .. }
| AIAgentContext::PullRequest { .. }
| AIAgentContext::File(_)
| AIAgentContext::Skills { .. } => {}
}
+73 -60
View File
@@ -1,44 +1,35 @@
pub mod helper;
pub mod transaction;
use std::{
collections::{HashMap, HashSet},
fmt::Display,
ops::Deref,
};
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::ops::Deref;
use chrono::DateTime;
use ai::skills::SkillPathOrigin;
use field_mask::{FieldMaskError, FieldMaskOperation};
use helper::{MessageExt, SubagentExt, ToolCallExt};
use itertools::Itertools;
use prost_types::FieldMask;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use warp_multi_agent_api::{
self as api,
message::{tool_call::subagent::Metadata, Message},
};
use warp_multi_agent_api::message::tool_call::subagent::Metadata;
use warp_multi_agent_api::message::Message;
use warp_multi_agent_api::{self as api};
use crate::{
ai::{
agent::comment::CodeReview,
document::ai_document_model::{AIDocumentId, AIDocumentVersion},
},
server::datetime_ext::DateTimeExt,
terminal::model::block::BlockId,
AIAgentTodoList,
use super::api::convert_conversation::convert_tool_call_result_to_input;
use super::api::{
user_inputs_from_messages, ConversionParams, ConvertAPIMessageToClientOutputMessage,
};
use super::comment::CodeReview;
use super::conversation::{context_in_exchanges, update_todo_list_from_todo_op};
use super::{
api::{
convert_conversation::convert_tool_call_result_to_input, user_inputs_from_messages,
ConversionParams, ConvertAPIMessageToClientOutputMessage,
},
conversation::{context_in_exchanges, update_todo_list_from_todo_op},
AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputStatus, MaybeAIAgentOutputMessage, MessageId, MessageToAIAgentOutputMessageError,
Shared,
};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::terminal::model::block::BlockId;
use crate::AIAgentTodoList;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TaskId(String);
@@ -128,7 +119,6 @@ struct ServerTask {
}
mod optimistic {
use crate::terminal::model::block::BlockId;
#[derive(Debug, Clone)]
pub(super) struct CLIAgentSubtask {
@@ -176,6 +166,12 @@ pub struct Task {
/// List of `AIAgentExchange`s corresponding to messages contained in this task.
exchanges: Vec<AIAgentExchange>,
}
#[derive(Clone, Copy)]
pub(super) struct TaskMessageContext<'a> {
pub(super) current_todo_list: Option<&'a AIAgentTodoList>,
pub(super) active_code_review: Option<&'a CodeReview>,
pub(super) skill_path_origin: &'a SkillPathOrigin,
}
impl Task {
pub(super) fn new_optimistic_root() -> Self {
@@ -236,6 +232,7 @@ impl Task {
parent_task: Option<&api::Task>,
current_todo_list: Option<&AIAgentTodoList>,
active_code_review: Option<&CodeReview>,
skill_path_origin: &SkillPathOrigin,
) -> Result<Self, UpgradeOptimisticTaskError> {
match self.data {
TaskImpl::Optimistic(optimistic::Task::Root) => {
@@ -281,8 +278,11 @@ impl Task {
if let Err(e) = self.update_exchange_from_messages(
messages,
exchange_id,
current_todo_list,
active_code_review,
TaskMessageContext {
current_todo_list,
active_code_review,
skill_path_origin,
},
false,
) {
log::error!(
@@ -315,7 +315,8 @@ impl Task {
parent_task: &api::Task,
existing_exchange: &AIAgentExchange,
current_todo_list: Option<&AIAgentTodoList>,
current_comment_state: Option<&CodeReview>,
active_code_review: Option<&CodeReview>,
skill_path_origin: &SkillPathOrigin,
should_convert_input_messages: bool,
) -> Self {
let subagent_call_and_id = parent_task.messages.iter().find_map(|message| {
@@ -330,7 +331,7 @@ impl Task {
input: vec![],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: Default::default(),
start_time: DateTime::now().into(),
start_time: chrono::Local::now(),
finish_time: None,
time_to_first_token_ms: None,
working_directory: existing_exchange.working_directory.clone(),
@@ -368,8 +369,11 @@ impl Task {
me.update_exchange_from_messages(
messages_clone,
new_exchange_id,
current_todo_list,
current_comment_state,
TaskMessageContext {
current_todo_list,
active_code_review,
skill_path_origin,
},
should_convert_input_messages,
)
.expect("Exchange exists and output is in 'streaming' state.");
@@ -462,7 +466,7 @@ impl Task {
input: vec![],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: Default::default(),
start_time: DateTime::now().into(),
start_time: chrono::Local::now(),
finish_time: None,
time_to_first_token_ms: None,
working_directory: existing_exchange.working_directory.clone(),
@@ -603,6 +607,19 @@ impl Task {
self.try_get_source().ok()
}
pub(super) fn source_for_persistence(&self) -> Option<api::Task> {
match &self.data {
TaskImpl::Server(server_data) => Some(server_data.source.clone()),
// Optimistic root tasks have a client-generated UUID and no
// server-side identity yet. Persisting a stub `api::Task` for them
// produces an orphan row in `agent_tasks` that survives the later
// server-side upgrade and breaks restore by competing with the
// real server root for parentless-task selection. See QUALITY-774.
TaskImpl::Optimistic(optimistic::Task::Root) => None,
TaskImpl::Optimistic(optimistic::Task::CLIAgent(_)) => None,
}
}
pub fn messages(&self) -> impl Iterator<Item = &api::Message> {
self.source()
.into_iter()
@@ -677,8 +694,7 @@ impl Task {
&mut self,
messages: Vec<api::Message>,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
should_convert_input_messages: bool,
) -> Result<(), UpdateTaskError> {
if self.source().is_none() {
@@ -687,8 +703,7 @@ impl Task {
self.update_exchange_from_messages(
messages.clone(),
exchange_id,
current_todo_list,
current_comments,
message_context,
should_convert_input_messages,
)?;
self.try_get_source_mut()?.messages.extend(messages);
@@ -699,8 +714,7 @@ impl Task {
&mut self,
message: api::Message,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
mask: FieldMask,
should_convert_input_messages: bool,
) -> Result<&api::Message, UpdateTaskError> {
@@ -714,8 +728,7 @@ impl Task {
self.add_messages(
vec![message.clone()],
exchange_id,
current_todo_list,
current_comments,
message_context,
should_convert_input_messages,
)?;
return self
@@ -734,10 +747,13 @@ impl Task {
.exchange_mut(exchange_id)
.ok_or(UpdateTaskError::ExchangeNotFound)?;
exchange_to_update.upsert_output_for_message(
&id,
&updated_message,
current_todo_list,
current_comments,
ConversionParams {
task_id: &id,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
},
)?;
// Task message updates can carry tool call result updates with them,
@@ -782,8 +798,7 @@ impl Task {
&mut self,
message: api::Message,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
current_comments: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
mask: FieldMask,
) -> Result<&api::Message, UpdateTaskError> {
let Some((idx, existing_message)) = self
@@ -836,10 +851,13 @@ impl Task {
.exchange_mut(exchange_id)
.ok_or(UpdateTaskError::ExchangeNotFound)?;
exchange_to_update.upsert_output_for_message(
&id,
&updated_message,
current_todo_list,
current_comments,
ConversionParams {
task_id: &id,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
},
)?;
let source = self.try_get_source_mut()?;
@@ -964,8 +982,7 @@ impl Task {
&mut self,
messages: Vec<api::Message>,
exchange_id: AIAgentExchangeId,
current_todo_list: Option<&AIAgentTodoList>,
active_code_review: Option<&CodeReview>,
message_context: TaskMessageContext<'_>,
should_convert_input_messages: bool,
) -> Result<(), UpdateTaskError> {
let exchange = self
@@ -1007,8 +1024,9 @@ impl Task {
.filter_map(|m| {
match m.to_client_output_message(ConversionParams {
task_id: &self.id,
current_todo_list,
active_code_review,
current_todo_list: message_context.current_todo_list,
active_code_review: message_context.active_code_review,
skill_path_origin: message_context.skill_path_origin,
}) {
Ok(MaybeAIAgentOutputMessage::Message(m)) => Some(Ok(m)),
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation) => None,
@@ -1043,10 +1061,8 @@ impl AIAgentExchange {
/// Note: this means updates will insert a new entry after previously added entries.
fn upsert_output_for_message(
&self,
task_id: &TaskId,
task_message: &api::Message,
todo_list: Option<&AIAgentTodoList>,
comments: Option<&CodeReview>,
conversion_params: super::api::ConversionParams<'_>,
) -> Result<(), UpdateTaskError> {
if let AIAgentOutputStatus::Streaming {
output: Some(output),
@@ -1079,11 +1095,8 @@ impl AIAgentExchange {
match task_message
.clone()
.to_client_output_message(ConversionParams {
current_todo_list: todo_list,
active_code_review: comments,
task_id,
})? {
.to_client_output_message(conversion_params)?
{
MaybeAIAgentOutputMessage::Message(m) => {
log::info!(
"[bedrock-debug] upsert_output_for_message: client_message_type={:?}",
+4
View File
@@ -139,6 +139,10 @@ impl ToolExt for api::message::tool_call::Tool {
Tool::AskUserQuestion(_) => "ask_user_question",
Tool::SendMessageToAgent(_) => "send_message_to_agent",
Tool::TransferShellCommandControlToUser(_) => "transfer_shell_command_control",
Tool::RunAgents(_) => "orchestrate",
// Matches the legacy server-handled name so analytics don't
// double-count the rollout.
Tool::WaitForEvents(_) => "wait_for_events",
}
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
use std::collections::HashMap;
use crate::ai::agent::task::TaskId;
use super::Task;
use crate::ai::agent::task::TaskId;
/// Keeps track of the state of tasks before they are modified.
/// Messages are assumed to be only updated during the same transaction
+54 -19
View File
@@ -2,18 +2,11 @@ use std::collections::HashMap;
use warp_multi_agent_api as api;
use crate::ai::{
agent::{AIAgentContext, AIAgentInput},
skills::SkillDescriptor,
};
use super::{
task::{
helper::{MessageExt, ToolCallExt},
Task, TaskId,
},
AIAgentExchange, AIAgentExchangeId, AIAgentOutputMessageType,
};
use super::task::helper::{MessageExt, ToolCallExt};
use super::task::{Task, TaskId};
use super::{AIAgentExchange, AIAgentExchangeId, AIAgentOutputMessageType};
use crate::ai::agent::{AIAgentContext, AIAgentInput};
use crate::ai::skills::SkillDescriptor;
#[derive(Debug, Clone)]
struct ExchangeRef {
@@ -27,6 +20,12 @@ pub struct TaskStore {
root_task_id: TaskId,
tasks: HashMap<TaskId, Task>,
linearized_refs: Vec<ExchangeRef>,
exchange_id_index: HashMap<AIAgentExchangeId, ExchangeRef>,
/// If the root task was upgraded from an optimistic (client-generated) ID
/// to a server-assigned ID, stores the original optimistic ID so that
/// deferred event handlers referencing the stale ID can still resolve
/// the task via `root_task_id`.
optimistic_root_task_id: Option<TaskId>,
}
impl TaskStore {
@@ -35,7 +34,9 @@ impl TaskStore {
let mut store = Self {
tasks: HashMap::new(),
linearized_refs: Vec::new(),
exchange_id_index: HashMap::new(),
root_task_id: root_task_id.clone(),
optimistic_root_task_id: None,
};
store.tasks.insert(root_task_id, root_task);
store.rebuild_linearized_refs_index();
@@ -48,7 +49,9 @@ impl TaskStore {
let mut store = Self {
tasks,
linearized_refs: Vec::new(),
exchange_id_index: HashMap::new(),
root_task_id,
optimistic_root_task_id: None,
};
store.rebuild_linearized_refs_index();
store
@@ -59,7 +62,10 @@ impl TaskStore {
}
pub fn get(&self, task_id: &TaskId) -> Option<&Task> {
self.tasks.get(task_id)
self.tasks.get(task_id).or_else(|| {
let old_id = self.optimistic_root_task_id.as_ref()?;
(old_id == task_id).then(|| self.tasks.get(&self.root_task_id))?
})
}
pub fn tasks(&self) -> impl Iterator<Item = &Task> {
@@ -104,15 +110,15 @@ impl TaskStore {
None
}
/// Modifies a task via the provided closure and rebuilds the exchange index
/// if exchanges changed.
/// Modifies a task via the provided closure and rebuilds the exchange index if the exchange
/// count changes.
pub fn modify_task<R>(
&mut self,
task_id: &TaskId,
f: impl FnOnce(&mut Task) -> R,
) -> Option<R> {
let exchange_count_before = self.tasks.get(task_id)?.exchanges_len();
let task = self.tasks.get_mut(task_id)?;
let exchange_count_before = task.exchanges_len();
let result = f(task);
let exchange_count_after = self
.tasks
@@ -142,10 +148,39 @@ impl TaskStore {
self.remove(&old_root_id);
let new_root_id = root_task.id().clone();
if old_root_id != new_root_id {
self.optimistic_root_task_id = Some(old_root_id);
}
self.root_task_id = new_root_id;
self.insert(root_task);
}
pub fn exchange_by_id(&self, exchange_id: AIAgentExchangeId) -> Option<&AIAgentExchange> {
let exchange_ref = self.exchange_id_index.get(&exchange_id)?;
self.lookup_exchange(exchange_ref)
}
pub(super) fn rebuild_exchange_id_index(&mut self) {
self.exchange_id_index = self
.tasks
.values()
.flat_map(|task| {
let task_id = task.id().clone();
task.exchanges()
.enumerate()
.map(move |(exchange_index, exchange)| {
(
exchange.id,
ExchangeRef {
task_id: task_id.clone(),
exchange_index,
},
)
})
})
.collect();
}
pub fn first_exchange(&self) -> Option<&AIAgentExchange> {
self.linearized_refs
.first()
@@ -266,7 +301,7 @@ impl TaskStore {
pub fn remove(&mut self, task_id: &TaskId) -> Option<Task> {
let task = self.tasks.remove(task_id)?;
self.linearized_refs.retain(|r| &r.task_id != task_id);
self.rebuild_linearized_refs_index();
Some(task)
}
@@ -280,6 +315,7 @@ impl TaskStore {
/// Rebuilds the linearized index from scratch using DFS traversal.
fn rebuild_linearized_refs_index(&mut self) {
self.linearized_refs = Self::build_linearized_refs(&self.tasks, &self.root_task_id);
self.rebuild_exchange_id_index();
}
/// Builds linearized exchange refs via DFS traversal without mutating self.
@@ -330,9 +366,8 @@ impl TaskStore {
#[cfg(test)]
mod testing {
use crate::ai::agent::task::TaskId;
use super::TaskStore;
use crate::ai::agent::task::TaskId;
impl TaskStore {
pub fn contains(&self, task_id: &TaskId) -> bool {
+125 -12
View File
@@ -3,17 +3,14 @@ use std::collections::HashSet;
use chrono::Local;
use uuid::Uuid;
use crate::ai::{
agent::{
task::{Task, TaskId},
AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentOutputStatus, FinishedAIAgentOutput, MessageId, Shared,
SubagentCall,
},
llms::LLMId,
};
use super::TaskStore;
use crate::ai::agent::task::{Task, TaskId};
use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentOutputStatus, FinishedAIAgentOutput, MessageId, Shared,
SubagentCall,
};
use crate::ai::llms::LLMId;
fn create_test_exchange() -> AIAgentExchange {
AIAgentExchange {
@@ -216,7 +213,8 @@ fn test_set_root_task_replaces_old() {
assert_eq!(store.task_count(), 1);
assert_eq!(store.exchange_count(), 3);
assert!(store.get(&task1_id).is_none());
// task1_id is now aliased to the new root task via optimistic_root_task_id
assert!(store.get(&task1_id).is_some());
assert!(store.get(&task2_id).is_some());
assert_eq!(store.root_task_id(), &task2_id);
@@ -485,7 +483,6 @@ fn test_linearization_nested_subtasks() {
let grandchild_exchange_id = grandchild_subtask.exchanges().next().unwrap().id;
// Create child subtask with a call to grandchild
use crate::terminal::model::block::BlockId;
let mut child_subtask = Task::new_optimistic_cli_agent_subtask(BlockId::new(), None);
let child_id = child_subtask.id().clone();
@@ -623,3 +620,119 @@ fn test_all_exchanges_by_task_with_subtasks() {
assert_eq!(by_task[2].1.len(), 1);
assert_eq!(by_task[2].1[0].id, root_exchange3_id);
}
#[test]
fn test_exchange_by_id_resolves_subtask_exchange() {
// The index spans all tasks, not just the root, so exchanges that live in a
// subtask must be resolvable by id.
let root_task = create_test_task_with_exchanges(1);
let root_task_id = root_task.id().clone();
let mut store = TaskStore::with_root_task(root_task);
let subtask = create_test_subtask_with_exchanges(2);
let subtask_id = subtask.id().clone();
let subtask_exchange_ids: Vec<_> = subtask.exchanges().map(|e| e.id).collect();
let subagent_exchange = create_exchange_with_subagent_call(&subtask_id);
store.append_exchange(&root_task_id, subagent_exchange);
store.insert(subtask);
for id in &subtask_exchange_ids {
assert_eq!(store.exchange_by_id(*id).map(|e| e.id), Some(*id));
}
}
#[test]
fn test_exchange_by_id_after_remove_task() {
let root_task = create_test_task_with_exchanges(1);
let root_task_id = root_task.id().clone();
let root_exchange_id = root_task.exchanges().next().unwrap().id;
let mut store = TaskStore::with_root_task(root_task);
let subtask = create_test_subtask_with_exchanges(2);
let subtask_id = subtask.id().clone();
let subtask_exchange_ids: Vec<_> = subtask.exchanges().map(|e| e.id).collect();
let subagent_exchange = create_exchange_with_subagent_call(&subtask_id);
store.append_exchange(&root_task_id, subagent_exchange);
store.insert(subtask);
// Sanity: the subtask's exchanges resolve before removal.
assert!(store.exchange_by_id(subtask_exchange_ids[0]).is_some());
store.remove(&subtask_id);
// The removed task's exchanges are no longer resolvable.
for id in &subtask_exchange_ids {
assert!(store.exchange_by_id(*id).is_none());
}
// The surviving root exchange still resolves.
assert_eq!(
store.exchange_by_id(root_exchange_id).map(|e| e.id),
Some(root_exchange_id)
);
}
#[test]
fn test_exchange_by_id_after_remove_task_exchange_index_shift() {
let task = create_test_task_with_exchanges(3);
let task_id = task.id().clone();
let exchange_ids: Vec<_> = task.exchanges().map(|e| e.id).collect();
let mut store = TaskStore::with_root_task(task);
// Remove the middle exchange, which shifts the index of everything after it.
store.remove_task_exchange(&task_id, exchange_ids[1]);
// The removed id no longer resolves.
assert!(store.exchange_by_id(exchange_ids[1]).is_none());
// The exchange that followed it (now at a different index) still resolves to itself.
assert_eq!(
store.exchange_by_id(exchange_ids[2]).map(|e| e.id),
Some(exchange_ids[2])
);
// The exchange before it is unaffected.
assert_eq!(
store.exchange_by_id(exchange_ids[0]).map(|e| e.id),
Some(exchange_ids[0])
);
}
#[test]
fn test_exchange_by_id_after_modify_task_append() {
let task = create_test_task_with_exchanges(2);
let task_id = task.id().clone();
let mut store = TaskStore::with_root_task(task);
let new_exchange = create_test_exchange();
let new_exchange_id = new_exchange.id;
store.modify_task(&task_id, |task| {
task.append_exchange(new_exchange);
});
// The newly appended exchange is found via the rebuilt index.
assert_eq!(
store.exchange_by_id(new_exchange_id).map(|e| e.id),
Some(new_exchange_id)
);
}
#[test]
fn test_exchange_by_id_after_set_root_task() {
let task1 = create_test_task_with_exchanges(2);
let task1_exchange_ids: Vec<_> = task1.exchanges().map(|e| e.id).collect();
let mut store = TaskStore::with_root_task(task1);
let task2 = create_test_task_with_exchanges(3);
let task2_exchange_ids: Vec<_> = task2.exchanges().map(|e| e.id).collect();
store.set_root_task(task2);
// The old root's exchanges no longer resolve.
for id in &task1_exchange_ids {
assert!(store.exchange_by_id(*id).is_none());
}
// The new root's exchanges resolve.
for id in &task2_exchange_ids {
assert_eq!(store.exchange_by_id(*id).map(|e| e.id), Some(*id));
}
}
+12 -7
View File
@@ -1,5 +1,11 @@
use std::collections::HashSet;
use ai::skills::SkillPathOrigin;
use chrono::Local;
use prost_types::FieldMask;
use warp_multi_agent_api as api;
use super::{ExtractMessagesError, Task, TaskMessageContext};
use crate::ai::agent::{
AIAgentActionType, AIAgentExchange, AIAgentOutput, AIAgentOutputMessageType,
AIAgentOutputStatus, MessageId, Shared,
@@ -8,11 +14,6 @@ use crate::ai::llms::LLMId;
use crate::test_util::ai_agent_tasks::{
create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message,
};
use chrono::Local;
use prost_types::FieldMask;
use warp_multi_agent_api as api;
use super::{ExtractMessagesError, Task};
/// Creates a Task backed by server data from the given api::Task.
fn create_server_task(api_task: api::Task) -> Task {
@@ -47,6 +48,7 @@ fn create_start_agent_tool_call_message(
prompt: &str,
) -> api::Message {
api::Message {
fetched_memories: vec![],
id: id.to_string(),
task_id: task_id.to_string(),
server_message_data: String::new(),
@@ -113,8 +115,11 @@ fn test_upsert_message_adds_start_agent_prompt_to_output() {
"run tests",
),
exchange_id,
None,
None,
TaskMessageContext {
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
},
FieldMask {
paths: vec!["message.tool_call".to_string()],
},
+11 -21
View File
@@ -1,18 +1,14 @@
use galaxyui::{AppContext, SingletonEntity};
use serde::Serialize;
use crate::ai::llms::LLMId;
use crate::CloudModel;
use crate::{
server::telemetry::AgentModeCitation as CitationForTelemetry,
terminal::view::block_onboarding::onboarding_agentic_suggestions_block::OnboardingChipType,
};
use super::conversation::AIConversationId;
use super::{
AIAgentCitation, AIAgentExchangeId, EntrypointType, PassiveSuggestionTriggerType,
ServerOutputId,
};
use crate::ai::llms::LLMId;
use crate::server::telemetry::AgentModeCitation as CitationForTelemetry;
use crate::CloudModel;
pub trait ForTelemetry {
type Output;
@@ -37,6 +33,14 @@ impl ForTelemetry for AIAgentCitation {
Some(CitationForTelemetry::WarpDocs { page: path.clone() })
}
Self::WebPage { url } => Some(CitationForTelemetry::WebPage { url: url.clone() }),
Self::AgentMemory {
memory_store_id,
memory_id,
..
} => Some(CitationForTelemetry::AgentMemory {
memory_store_id: memory_store_id.clone(),
memory_id: memory_id.clone(),
}),
}
}
}
@@ -44,20 +48,6 @@ impl ForTelemetry for AIAgentCitation {
impl EntrypointType {
pub fn entrypoint(&self) -> String {
match self {
Self::Onboarding { chip_type } => {
format!(
"ONBOARDING.{}",
match chip_type {
OnboardingChipType::FixAnIssue => "FIX_AN_ISSUE",
OnboardingChipType::PullCloudLogs => "PULL_CLOUD_LOGS",
OnboardingChipType::StartAFeature => "START_A_FEATURE",
OnboardingChipType::PythonSnakeGame => "PYTHON_SNAKE_GAME",
OnboardingChipType::ExploreGitHistory => "EXPLORE_GIT_HISTORY",
OnboardingChipType::MatrixThemePicker => "MATRIX_THEME_PICKER",
OnboardingChipType::Other => "OTHER",
}
)
}
Self::PromptSuggestion {
is_static,
is_coding,
+1 -2
View File
@@ -1,6 +1,5 @@
use crate::ai::agent::AIAgentTodo;
use super::AIAgentTodoId;
use crate::ai::agent::AIAgentTodo;
pub(crate) mod popup;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+17 -17
View File
@@ -1,27 +1,24 @@
use crate::ai::blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ClippedScrollStateHandle, ClippedScrollable, Dismiss, Empty, Expanded, ParentElement,
SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable,
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dismiss, DropShadow, Empty, Expanded, Flex, MainAxisSize, ParentElement,
Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Text,
};
use galaxyui::fonts::FamilyId;
use galaxyui::ModelHandle;
use galaxyui::SingletonEntity;
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisSize, Radius, Text,
},
fonts::{Properties, Weight},
keymap::FixedBinding,
AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use pathfinder_color::ColorU;
use crate::ai::agent::icons::{in_progress_icon, pending_icon, succeeded_icon};
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
BlocklistAIHistoryModel,
};
use crate::ui_components::blended_colors;
pub struct AgentTodosPopupView {
@@ -86,8 +83,11 @@ impl AgentTodosPopupView {
event: &BlocklistAIHistoryEvent,
ctx: &mut ViewContext<Self>,
) {
if let BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id } = event {
if *terminal_view_id == self.terminal_view_id {
if let BlocklistAIHistoryEvent::UpdatedTodoList {
terminal_surface_id,
} = event
{
if *terminal_surface_id == self.terminal_view_id {
ctx.notify();
}
}
+10 -7
View File
@@ -1,9 +1,6 @@
use super::{
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AgentOutputTable, ProgrammingLanguage,
};
use crate::code::editor_management::CodeSource;
use crate::features::FeatureFlag;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::gfm_table::{format_gfm_table, maybe_collect_gfm_table_lines};
use galaxy_util::path::LineAndColumnArg;
use itertools::Itertools;
@@ -13,7 +10,13 @@ use markdown_parser::{
};
use mermaid_to_svg::is_mermaid_diagram;
use regex::Regex;
use std::{collections::HashMap, path::PathBuf};
use super::{
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AgentOutputTable, ProgrammingLanguage,
};
use crate::code::editor_management::CodeSource;
use crate::features::FeatureFlag;
lazy_static! {
/// Markdown prefix for code blocks. Matches on triple backticks followed by a language.