Persist exact provider history and archived tool results independently of terminal scrollback, and retain them when active run checkpoints are cleared. Keep conversation and session saving independent of automatic reopening. Deduplicate only committed session snapshots, retry transient SQLite write contention, and enqueue conversation updates before writer shutdown. Recover healthy startup data independently and protect unread records by disabling writes with a persistent warning after an incomplete restore. Retain local transcript pane conversation links across restarts. Add regression coverage for SQLite failures, context round trips, legacy fallbacks, and transcript snapshots.
2188 lines
97 KiB
Rust
2188 lines
97 KiB
Rust
//! Conversions from MAA API types to application types, for loading and restoring conversations.
|
|
//! Contains logic necessary for converting proto tasks to application exchanges and conversations.
|
|
//! Whenever adding new MAA types, conversions here must be updated for restoration and loading to work.
|
|
//! 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 galaxy_core::command::ExitCode;
|
|
use persistence::model::AgentConversationData;
|
|
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::api::is_internal_command_completion_assessment;
|
|
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::{
|
|
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentExchange,
|
|
AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputStatus,
|
|
CallMCPToolResult, CancellationReason, CloneRepositoryURL, CreateDocumentsResult,
|
|
DocumentContext, EditDocumentsResult, FileContext, FileGlobResult, FileGlobV2Match,
|
|
FileGlobV2Result, FinishedAIAgentOutput, GrepFileMatch, GrepLineMatch, GrepResult,
|
|
ImageContext, InsertReviewCommentsResult, OutputModelInfo, PassiveCodeDiffEntry,
|
|
PassiveSuggestionResultType, PassiveSuggestionTrigger, ReadDocumentsResult, ReadFilesResult,
|
|
ReadMCPResourceResult, ReadShellCommandOutputResult, RequestCommandOutputResult,
|
|
RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId,
|
|
Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult,
|
|
SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext,
|
|
UploadArtifactResult, UserQueryMode, WriteToLongRunningShellCommandResult,
|
|
};
|
|
use crate::ai::block_context::BlockContext;
|
|
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
|
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;
|
|
|
|
/// How to restore a conversation from the cloud.
|
|
pub enum RestorationMode {
|
|
/// Continue the same conversation (use the same server ID).
|
|
Continue,
|
|
/// Fork from the original conversation.
|
|
#[allow(dead_code)]
|
|
Fork,
|
|
}
|
|
|
|
/// Converts a cloud ConversationData to an AIConversation.
|
|
/// The `metadata` contains all server-side information about the conversation, including usage data.
|
|
/// `restoration_mode` controls how the server metadata is handled - we should only keep the metadata when continuing, not forking
|
|
pub fn convert_conversation_data_to_ai_conversation(
|
|
conversation_id: AIConversationId,
|
|
conversation_data: &api::ConversationData,
|
|
metadata: ServerAIConversationMetadata,
|
|
restoration_mode: RestorationMode,
|
|
) -> Option<AIConversation> {
|
|
let usage_metadata = Some(metadata.usage.clone());
|
|
|
|
let agent_conversation_data = match restoration_mode {
|
|
RestorationMode::Fork => AgentConversationData {
|
|
agent_backend: Default::default(),
|
|
provider_context: None,
|
|
active_provider_run_json: None,
|
|
local_agent_groups: Vec::new(),
|
|
tool_titles: Default::default(),
|
|
server_conversation_token: None,
|
|
conversation_usage_metadata: usage_metadata,
|
|
reverted_action_ids: None,
|
|
forked_from_server_conversation_token: Some(
|
|
metadata.server_conversation_token.as_str().to_string(),
|
|
),
|
|
// If we fork, new conversation, artifacts don't carry over
|
|
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,
|
|
pinned: false,
|
|
progressive_summary: None,
|
|
messages_summarized_up_to: 0,
|
|
},
|
|
RestorationMode::Continue => AgentConversationData {
|
|
agent_backend: Default::default(),
|
|
provider_context: None,
|
|
active_provider_run_json: None,
|
|
local_agent_groups: Vec::new(),
|
|
tool_titles: Default::default(),
|
|
server_conversation_token: Some(
|
|
metadata.server_conversation_token.as_str().to_string(),
|
|
),
|
|
conversation_usage_metadata: usage_metadata,
|
|
reverted_action_ids: None,
|
|
forked_from_server_conversation_token: None,
|
|
artifacts_json: serde_json::to_string(&metadata.artifacts).ok(),
|
|
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: metadata
|
|
.ambient_agent_task_id
|
|
.map(|task_id| task_id.to_string()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
pinned: false,
|
|
progressive_summary: None,
|
|
messages_summarized_up_to: 0,
|
|
},
|
|
};
|
|
|
|
match AIConversation::new_restored(
|
|
conversation_id,
|
|
conversation_data.tasks.clone(),
|
|
Some(agent_conversation_data),
|
|
) {
|
|
Ok(mut conversation) => {
|
|
// Set the server metadata only if we're continuing
|
|
// If we're forking, this should be treated as a brand new conversation that doesn't have server metadata yet.
|
|
// After the first request, server metadata will be populated.
|
|
if matches!(restoration_mode, RestorationMode::Continue) {
|
|
conversation.set_server_metadata(metadata);
|
|
}
|
|
Some(conversation)
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Failed to convert ConversationData to AIConversation: {e:?}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Converts InputContext from the API to the application type `Arc<[AIAgentContext]>`
|
|
#[allow(clippy::single_range_in_vec_init)]
|
|
pub(crate) fn convert_input_context(context: Option<&api::InputContext>) -> Arc<[AIAgentContext]> {
|
|
let Some(context) = context else {
|
|
return Arc::new([]);
|
|
};
|
|
|
|
let mut result = Vec::new();
|
|
|
|
// Convert executed shell commands
|
|
#[allow(deprecated)]
|
|
for executed_shell_command in &context.executed_shell_commands {
|
|
if !executed_shell_command.command.is_empty() {
|
|
result.push(AIAgentContext::Block(Box::new(BlockContext {
|
|
id: BlockId::default(),
|
|
index: BlockIndex::from(0),
|
|
command: executed_shell_command.command.clone(),
|
|
output: executed_shell_command.output.clone(),
|
|
exit_code: ExitCode::from(executed_shell_command.exit_code),
|
|
is_auto_attached: executed_shell_command.is_auto_attached,
|
|
started_ts: executed_shell_command
|
|
.started_ts
|
|
.as_ref()
|
|
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
|
|
finished_ts: executed_shell_command
|
|
.finished_ts
|
|
.as_ref()
|
|
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
|
|
pwd: None,
|
|
shell: None,
|
|
username: None,
|
|
hostname: None,
|
|
git_branch: None,
|
|
os: None,
|
|
session_id: None,
|
|
})));
|
|
}
|
|
}
|
|
|
|
// Convert directory context
|
|
if let Some(directory) = &context.directory {
|
|
result.push(AIAgentContext::Directory {
|
|
pwd: if directory.pwd.is_empty() {
|
|
None
|
|
} else {
|
|
Some(directory.pwd.clone())
|
|
},
|
|
home_dir: if directory.home.is_empty() {
|
|
None
|
|
} else {
|
|
Some(directory.home.clone())
|
|
},
|
|
are_file_symbols_indexed: directory.pwd_file_symbols_indexed,
|
|
});
|
|
}
|
|
|
|
// Convert operating system and shell to execution environment
|
|
if let (Some(os), Some(shell)) = (&context.operating_system, &context.shell) {
|
|
result.push(AIAgentContext::ExecutionEnvironment(
|
|
WarpAiExecutionContext {
|
|
os: WarpAiOsContext {
|
|
category: if os.platform.is_empty() {
|
|
None
|
|
} else {
|
|
Some(os.platform.clone())
|
|
},
|
|
distribution: if os.distribution.is_empty() {
|
|
None
|
|
} else {
|
|
Some(os.distribution.clone())
|
|
},
|
|
},
|
|
shell_name: shell.name.clone(),
|
|
shell_version: if shell.version.is_empty() {
|
|
None
|
|
} else {
|
|
Some(shell.version.clone())
|
|
},
|
|
},
|
|
));
|
|
}
|
|
|
|
// Convert current time
|
|
if let Some(current_time) = &context.current_time {
|
|
let datetime = proto_timestamp_to_local_datetime(current_time.seconds, current_time.nanos);
|
|
result.push(AIAgentContext::CurrentTime {
|
|
current_time: datetime,
|
|
});
|
|
}
|
|
|
|
// Convert selected text
|
|
for selected_text in &context.selected_text {
|
|
if !selected_text.text.is_empty() {
|
|
result.push(AIAgentContext::SelectedText(selected_text.text.clone()));
|
|
}
|
|
}
|
|
|
|
// Convert images
|
|
for image in &context.images {
|
|
if !image.data.is_empty() {
|
|
let mime_type = if image.mime_type.is_empty() {
|
|
"image/jpeg".to_string() // Default MIME type
|
|
} else {
|
|
image.mime_type.clone()
|
|
};
|
|
|
|
// Convert binary data to base64
|
|
use base64::engine::general_purpose;
|
|
use base64::Engine;
|
|
let data = general_purpose::STANDARD.encode(&image.data);
|
|
|
|
result.push(AIAgentContext::Image(ImageContext {
|
|
data,
|
|
mime_type,
|
|
file_name: "image".to_string(), // Default file name since proto doesn't have it
|
|
is_figma: false, // This field is only used for detecting Figma pngs in the input and is not meaningful for restored conversations.
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Convert files
|
|
for file in &context.files {
|
|
if let Some(content) = &file.content {
|
|
let file_context = FileContext::from(content.clone());
|
|
|
|
if !file_context.file_name.is_empty() && !file_context.content.is_empty() {
|
|
result.push(AIAgentContext::File(file_context.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert project rules
|
|
for project_rules in &context.project_rules {
|
|
if !project_rules.root_path.is_empty()
|
|
&& (!project_rules.active_rule_files.is_empty()
|
|
|| !project_rules.additional_rule_file_paths.is_empty())
|
|
{
|
|
result.push(AIAgentContext::ProjectRules {
|
|
root_path: project_rules.root_path.clone(),
|
|
active_rules: project_rules
|
|
.active_rule_files
|
|
.iter()
|
|
.map(|file_content| FileContext::from(file_content.clone()))
|
|
.collect(),
|
|
additional_rule_paths: project_rules.additional_rule_file_paths.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert codebases
|
|
for codebase in &context.codebases {
|
|
if !codebase.path.is_empty() && !codebase.name.is_empty() {
|
|
result.push(AIAgentContext::Codebase {
|
|
path: codebase.path.clone(),
|
|
name: codebase.name.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
result.into()
|
|
}
|
|
|
|
/// Trait for converting task messages into AIAgentExchange objects
|
|
/// for display in the UI as restored AI blocks.
|
|
pub trait ConvertToExchanges {
|
|
fn into_exchanges(self) -> Vec<AIAgentExchange>;
|
|
}
|
|
|
|
impl ConvertToExchanges for &api::Task {
|
|
/// Converts a list of tasks into AIAgentExchange objects.
|
|
///
|
|
/// Note: for now, we only restore messages from the root task (task with no parent).
|
|
fn into_exchanges(self) -> Vec<AIAgentExchange> {
|
|
let mut exchanges = Vec::new();
|
|
let mut todo_lists: Vec<AIAgentTodoList> = Vec::new();
|
|
|
|
// Build a map of message_id -> message for quick lookup
|
|
let mut message_map: HashMap<&str, &api::Message> = HashMap::new();
|
|
// Build a map of tool_call_id -> tool_call for cancelled results
|
|
let mut tool_call_map: HashMap<String, &api::message::ToolCall> = HashMap::new();
|
|
for message in &self.messages {
|
|
message_map.insert(message.id.as_str(), message);
|
|
// If this is a tool call message, add it to the tool call map
|
|
if let Some(api::message::Message::ToolCall(tool_call)) = &message.message {
|
|
tool_call_map.insert(tool_call.tool_call_id.clone(), tool_call);
|
|
}
|
|
}
|
|
|
|
// Process messages in chronological order
|
|
let mut current_inputs = Vec::new();
|
|
let mut current_outputs = Vec::new();
|
|
let mut current_message_ids = HashSet::new();
|
|
let mut document_versions: HashMap<AIDocumentId, AIDocumentVersion> = HashMap::new();
|
|
let mut current_request_id: Option<String> = None;
|
|
|
|
// Almost all messages should be ingested as outputs, except for some special cases:
|
|
// 1. User queries
|
|
// 2. System queries, but only if they are displayed as user queries/initiate conversations, like queries:
|
|
// * from the new project flow (displayed as user queries)
|
|
// * from the clone repository flow (displayed as user queries)
|
|
// * from auto code diff queries (initiate new conversations)
|
|
// 3. tool call results (as we also render these like inputs)
|
|
for api_message in self.messages.iter() {
|
|
let Some(message) = &api_message.message else {
|
|
continue;
|
|
};
|
|
|
|
let task_id = TaskId::new(api_message.task_id.clone());
|
|
// Check if request_id has changed - if so, create an exchange from accumulated messages
|
|
let message_request_id = if api_message.request_id.is_empty() {
|
|
None
|
|
} else {
|
|
Some(api_message.request_id.clone())
|
|
};
|
|
|
|
// Create exchange if request_id changed and we have accumulated messages
|
|
if message_request_id != current_request_id
|
|
&& current_request_id.is_some()
|
|
&& (!current_inputs.is_empty() || !current_outputs.is_empty())
|
|
{
|
|
let is_output_tool_call_canceled = current_inputs
|
|
.last()
|
|
.and_then(|input: &AIAgentInput| input.action_result())
|
|
.map(|result| result.result.is_cancelled())
|
|
.unwrap_or(false);
|
|
|
|
if let Some(exchange) = create_exchange_from_messages(
|
|
¤t_inputs,
|
|
¤t_outputs,
|
|
is_output_tool_call_canceled,
|
|
¤t_message_ids,
|
|
&message_map,
|
|
current_request_id.as_deref(),
|
|
) {
|
|
exchanges.push(exchange);
|
|
}
|
|
current_inputs.clear();
|
|
current_outputs.clear();
|
|
current_message_ids.clear();
|
|
}
|
|
|
|
// Update current_request_id
|
|
current_request_id = message_request_id;
|
|
|
|
// Track this message ID for the current exchange
|
|
current_message_ids.insert(api_message.id.clone());
|
|
|
|
let added_message_as_exchange_input = match message {
|
|
api::message::Message::UserQuery(user_query) => {
|
|
if is_internal_command_completion_assessment(api_message) {
|
|
false
|
|
} else {
|
|
// Add user query as input
|
|
current_inputs.push(AIAgentInput::UserQuery {
|
|
query: user_query.query.clone(),
|
|
context: convert_input_context(user_query.context.as_ref()),
|
|
static_query_type: None,
|
|
referenced_attachments: HashMap::new(),
|
|
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
|
|
running_command: None,
|
|
intended_agent: Some(user_query.intended_agent()),
|
|
});
|
|
true
|
|
}
|
|
}
|
|
api::message::Message::SystemQuery(query) => {
|
|
let Some(query_type) = &query.r#type else {
|
|
continue;
|
|
};
|
|
|
|
match query_type {
|
|
api::message::system_query::Type::CreateNewProject(auto_code_diff) => {
|
|
current_inputs.push(AIAgentInput::UserQuery {
|
|
query: auto_code_diff.query.clone(),
|
|
context: convert_input_context(query.context.as_ref()),
|
|
static_query_type: None,
|
|
referenced_attachments: HashMap::new(),
|
|
user_query_mode: UserQueryMode::default(), // SystemQuery doesn't have mode field
|
|
running_command: None,
|
|
intended_agent: None,
|
|
});
|
|
true
|
|
}
|
|
api::message::system_query::Type::CloneRepository(clone_repo) => {
|
|
current_inputs.push(AIAgentInput::CloneRepository {
|
|
clone_repo_url: CloneRepositoryURL::new(clone_repo.url.clone()),
|
|
context: convert_input_context(query.context.as_ref()),
|
|
});
|
|
true
|
|
}
|
|
api::message::system_query::Type::AutoCodeDiff(auto_code_diff) => {
|
|
current_inputs.push(AIAgentInput::AutoCodeDiffQuery {
|
|
query: auto_code_diff.query.clone(),
|
|
context: convert_input_context(query.context.as_ref()),
|
|
});
|
|
true
|
|
}
|
|
api::message::system_query::Type::FetchReviewComments(fetch) => {
|
|
current_inputs.push(AIAgentInput::FetchReviewComments {
|
|
repo_path: fetch.repo_path.clone(),
|
|
context: convert_input_context(query.context.as_ref()),
|
|
});
|
|
true
|
|
}
|
|
// TriggerSuggestPrompt is not rendered as user input, so we don't want to include it as an input in the exchange.
|
|
// ResumeConversation is actually added to the task's messages as a plain UserQuery, so we don't expect to encounter it in the task's messages.
|
|
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(_)
|
|
// 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) => {
|
|
// Try to convert tool call result - returns None for ServerToolCalls
|
|
if let Some(input) = convert_tool_call_result_to_input(
|
|
&task_id,
|
|
tool_call_result,
|
|
&tool_call_map,
|
|
&mut document_versions,
|
|
) {
|
|
// Add tool call result as input
|
|
current_inputs.push(input);
|
|
}
|
|
|
|
true
|
|
}
|
|
api::message::Message::UpdateTodos(update) => {
|
|
if let Some(operation) = &update.operation {
|
|
update_todo_list_from_todo_op(&mut todo_lists, operation.clone());
|
|
}
|
|
|
|
false
|
|
}
|
|
api::message::Message::InvokeSkill(invoke_skill) => {
|
|
if let Some(api_skill) = invoke_skill.skill.clone() {
|
|
if let Ok(parsed_skill) = ParsedSkill::try_from_api_with_origin(
|
|
api_skill,
|
|
&SkillPathOrigin::RestoredDisplayOnly,
|
|
) {
|
|
let user_query = invoke_skill
|
|
.user_query
|
|
.clone()
|
|
.map(|user_query| crate::ai::agent::InvokeSkillUserQuery {
|
|
query: user_query.query,
|
|
// Restored conversations currently do not hydrate invoke-skill
|
|
// inline attachments back into client-side attachment structs.
|
|
// TODO(APP-3101): support rehydration of attachments.
|
|
referenced_attachments: HashMap::new(),
|
|
});
|
|
let input = AIAgentInput::InvokeSkill {
|
|
context: Arc::new([]),
|
|
skill: parsed_skill,
|
|
user_query,
|
|
};
|
|
current_inputs.push(input);
|
|
};
|
|
};
|
|
|
|
true
|
|
}
|
|
// Preserve EventsFromAgents as an explicit input in restored conversations
|
|
// so orchestration state (including lifecycle timestamps) survives roundtrip.
|
|
api::message::Message::EventsFromAgents(events) => {
|
|
current_inputs.push(AIAgentInput::EventsFromAgents {
|
|
events: events.agent_events.clone(),
|
|
});
|
|
true
|
|
}
|
|
api::message::Message::PassiveSuggestionResult(passive_result) => {
|
|
if let Some(input) =
|
|
convert_passive_suggestion_result_to_input(passive_result)
|
|
{
|
|
current_inputs.push(input);
|
|
}
|
|
true
|
|
}
|
|
api::message::Message::AgentOutput(_)
|
|
| api::message::Message::AgentReasoning(_)
|
|
| api::message::Message::Summarization(_)
|
|
| api::message::Message::ToolCall(_)
|
|
| api::message::Message::ServerEvent(_)
|
|
| api::message::Message::UpdateReviewComments(_)
|
|
| api::message::Message::CodeReview(_)
|
|
// TODO(advait): Handle this for restored + forked conversations w/ web searches/fetches.
|
|
| api::message::Message::WebSearch(_)
|
|
| api::message::Message::WebFetch(_)
|
|
| api::message::Message::DebugOutput(_)
|
|
| api::message::Message::ArtifactEvent(_)
|
|
| api::message::Message::MessagesReceivedFromAgents(_)
|
|
| api::message::Message::ModelUsed(_)
|
|
| api::message::Message::OrchestrationConfigSnapshot(_) => false,
|
|
};
|
|
|
|
if !added_message_as_exchange_input {
|
|
if let Ok(MaybeAIAgentOutputMessage::Message(output_msg)) = (*api_message)
|
|
.clone()
|
|
.to_client_output_message(ConversionParams {
|
|
current_todo_list: todo_lists.last(),
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// At the end, if we have remaining inputs or outputs, create the last exchange.
|
|
if !current_inputs.is_empty() || !current_outputs.is_empty() {
|
|
// If the last message is a tool call (i.e. we have no corresponding result)
|
|
// we will assume the tool call was cancelled.
|
|
let is_output_tool_call_canceled = self.messages.last().is_some_and(|message| {
|
|
matches!(message.message, Some(api::message::Message::ToolCall(_)))
|
|
});
|
|
|
|
if let Some(exchange) = create_exchange_from_messages(
|
|
¤t_inputs,
|
|
¤t_outputs,
|
|
is_output_tool_call_canceled,
|
|
¤t_message_ids,
|
|
&message_map,
|
|
current_request_id.as_deref(),
|
|
) {
|
|
exchanges.push(exchange);
|
|
}
|
|
}
|
|
|
|
exchanges
|
|
}
|
|
}
|
|
|
|
/// Convert a ToolCallResult to an AIAgentInput::ActionResult
|
|
/// Returns None if the tool call result is a ServerToolCallResult
|
|
/// `document_versions` tracks the latest version per document for CreateDocuments and EditDocuments results.
|
|
/// Each new document (CreateDocuments) starts at the default version; edits increment the specific document's version.
|
|
#[allow(clippy::single_range_in_vec_init)]
|
|
pub(crate) fn convert_tool_call_result_to_input(
|
|
task_id: &TaskId,
|
|
tool_call_result: &api::message::ToolCallResult,
|
|
tool_call_map: &HashMap<String, &api::message::ToolCall>,
|
|
document_versions: &mut HashMap<AIDocumentId, AIDocumentVersion>,
|
|
) -> Option<AIAgentInput> {
|
|
use warp_multi_agent_api::message::tool_call_result::Result as ToolCallResultType;
|
|
|
|
let tool_call_id = tool_call_result.tool_call_id.clone();
|
|
let context = convert_input_context(tool_call_result.context.as_ref());
|
|
|
|
match tool_call_result.result.as_ref() {
|
|
Some(ToolCallResultType::RunShellCommand(result)) => {
|
|
// Convert RunShellCommand result to RequestCommandOutputResult
|
|
let command_output_result = match &result.result {
|
|
Some(api::run_shell_command_result::Result::CommandFinished(finished)) => {
|
|
RequestCommandOutputResult::Completed {
|
|
block_id: finished.command_id.clone().into(),
|
|
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(
|
|
snapshot,
|
|
)) => RequestCommandOutputResult::LongRunningCommandSnapshot {
|
|
command: result.command.clone(),
|
|
block_id: snapshot.command_id.clone().into(),
|
|
grid_contents: snapshot.output.clone(),
|
|
cursor: snapshot.cursor.clone(),
|
|
is_alt_screen_active: snapshot.is_alt_screen_active,
|
|
},
|
|
Some(api::run_shell_command_result::Result::PermissionDenied(
|
|
api::PermissionDenied { .. },
|
|
))
|
|
| None => {
|
|
// If no result is present, treat as cancelled
|
|
RequestCommandOutputResult::CancelledBeforeExecution
|
|
}
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::RequestCommandOutput(command_output_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::WriteToLongRunningShellCommand(result)) => {
|
|
let write_result = match &result.result {
|
|
Some(api::write_to_long_running_shell_command_result::Result::LongRunningCommandSnapshot(
|
|
snapshot,
|
|
)) => WriteToLongRunningShellCommandResult::Snapshot {
|
|
block_id: snapshot.command_id.clone().into(),
|
|
grid_contents: snapshot.output.clone(),
|
|
cursor: snapshot.cursor.clone(),
|
|
is_alt_screen_active: snapshot.is_alt_screen_active,
|
|
is_preempted: snapshot.is_preempted,
|
|
},
|
|
Some(api::write_to_long_running_shell_command_result::Result::CommandFinished(
|
|
finished,
|
|
)) => WriteToLongRunningShellCommandResult::CommandFinished {
|
|
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(()))
|
|
})) => WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound),
|
|
Some(api::write_to_long_running_shell_command_result::Result::Error(_)) | None => WriteToLongRunningShellCommandResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::WriteToLongRunningShellCommand(write_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::ReadFiles(result)) => {
|
|
let read_result = match &result.result {
|
|
Some(api::read_files_result::Result::AnyFilesSuccess(success)) => {
|
|
let files = success
|
|
.files
|
|
.iter()
|
|
.map(|file| FileContext::from(file.clone()))
|
|
.collect();
|
|
|
|
ReadFilesResult::Success { files }
|
|
}
|
|
Some(api::read_files_result::Result::TextFilesSuccess(success)) => {
|
|
let files = success
|
|
.files
|
|
.iter()
|
|
.map(|file| FileContext::from(file.clone()))
|
|
.collect();
|
|
ReadFilesResult::Success { files }
|
|
}
|
|
Some(api::read_files_result::Result::Error(error)) => {
|
|
ReadFilesResult::Error(error.message.clone())
|
|
}
|
|
None => ReadFilesResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::ReadFiles(read_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::UploadFileArtifact(result)) => {
|
|
let upload_result = match &result.result {
|
|
Some(api::upload_file_artifact_result::Result::Success(success)) => {
|
|
UploadArtifactResult::Success {
|
|
artifact_uid: success.artifact_uid.clone(),
|
|
filepath: None,
|
|
mime_type: success.mime_type.clone(),
|
|
description: None,
|
|
size_bytes: success.size_bytes,
|
|
}
|
|
}
|
|
Some(api::upload_file_artifact_result::Result::Error(error)) => {
|
|
UploadArtifactResult::Error(error.message.clone())
|
|
}
|
|
None => UploadArtifactResult::Error(
|
|
"Upload artifact tool call returned no result".to_string(),
|
|
),
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::UploadArtifact(upload_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::SearchCodebase(result)) => {
|
|
let search_result = match &result.result {
|
|
Some(api::search_codebase_result::Result::Success(success)) => {
|
|
let files = success
|
|
.files
|
|
.iter()
|
|
.map(|file| FileContext::from(file.clone()))
|
|
.collect();
|
|
|
|
SearchCodebaseResult::Success { files }
|
|
}
|
|
Some(api::search_codebase_result::Result::Error(error)) => {
|
|
SearchCodebaseResult::Failed {
|
|
reason: SearchCodebaseFailureReason::ClientError,
|
|
message: error.message.clone(),
|
|
}
|
|
}
|
|
None => SearchCodebaseResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::SearchCodebase(search_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::ApplyFileDiffs(result)) => {
|
|
let edit_result = match &result.result {
|
|
Some(api::apply_file_diffs_result::Result::Success(success)) => {
|
|
let updated_files = success
|
|
.updated_files_v2
|
|
.iter()
|
|
.filter_map(|updated_file| {
|
|
updated_file.file.as_ref().map(|file| UpdatedFileContext {
|
|
was_edited_by_user: updated_file.was_edited_by_user,
|
|
file_context: FileContext::from(file.clone()),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
RequestFileEditsResult::Success {
|
|
diff: "".to_string(), // This is a legacy-only field that should be removed
|
|
updated_files,
|
|
deleted_files: success
|
|
.deleted_files
|
|
.iter()
|
|
.map(|f| f.file_path.clone())
|
|
.collect(),
|
|
// Line counts are not available in legacy persisted data
|
|
lines_added: 0,
|
|
lines_removed: 0,
|
|
}
|
|
}
|
|
Some(api::apply_file_diffs_result::Result::Error(error)) => {
|
|
RequestFileEditsResult::DiffApplicationFailed {
|
|
error: error.message.clone(),
|
|
}
|
|
}
|
|
None => RequestFileEditsResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::RequestFileEdits(edit_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::Grep(result)) => {
|
|
let grep_result = match &result.result {
|
|
Some(api::grep_result::Result::Success(success)) => {
|
|
let matched_files = success
|
|
.matched_files
|
|
.iter()
|
|
.map(|file| {
|
|
let matched_lines = file
|
|
.matched_lines
|
|
.iter()
|
|
.map(|line| GrepLineMatch {
|
|
line_number: line.line_number as usize,
|
|
})
|
|
.collect();
|
|
|
|
GrepFileMatch {
|
|
file_path: file.file_path.clone(),
|
|
matched_lines,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
GrepResult::Success {
|
|
matched_files,
|
|
truncated: false,
|
|
}
|
|
}
|
|
Some(api::grep_result::Result::Error(error)) => {
|
|
GrepResult::Error(error.message.clone())
|
|
}
|
|
None => GrepResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::Grep(grep_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
#[allow(deprecated)]
|
|
Some(ToolCallResultType::FileGlob(result)) => {
|
|
let glob_result = match &result.result {
|
|
Some(api::file_glob_result::Result::Success(success)) => FileGlobResult::Success {
|
|
matched_files: success.matched_files.clone(),
|
|
},
|
|
Some(api::file_glob_result::Result::Error(error)) => {
|
|
FileGlobResult::Error(error.message.clone())
|
|
}
|
|
None => FileGlobResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::FileGlob(glob_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::FileGlobV2(result)) => {
|
|
let glob_result = match &result.result {
|
|
Some(api::file_glob_v2_result::Result::Success(success)) => {
|
|
let matched_files = success
|
|
.matched_files
|
|
.iter()
|
|
.map(|file| FileGlobV2Match {
|
|
file_path: file.file_path.clone(),
|
|
})
|
|
.collect();
|
|
|
|
FileGlobV2Result::Success {
|
|
matched_files,
|
|
warnings: Some(success.warnings.clone()).filter(|s| !s.is_empty()),
|
|
}
|
|
}
|
|
Some(api::file_glob_v2_result::Result::Error(error)) => {
|
|
FileGlobV2Result::Error(error.message.clone())
|
|
}
|
|
None => FileGlobV2Result::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::FileGlobV2(glob_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::ReadMcpResource(result)) => {
|
|
let mcp_result = match &result.result {
|
|
Some(api::read_mcp_resource_result::Result::Success(success)) => {
|
|
let resource_contents = success
|
|
.contents
|
|
.iter()
|
|
.map(|content| {
|
|
match &content.content_type {
|
|
Some(api::mcp_resource_content::ContentType::Text(text)) => {
|
|
rmcp::model::ResourceContents::TextResourceContents {
|
|
uri: content.uri.clone(),
|
|
mime_type: Some(text.mime_type.clone()),
|
|
text: text.content.clone(),
|
|
meta: None,
|
|
}
|
|
}
|
|
Some(api::mcp_resource_content::ContentType::Binary(binary)) => {
|
|
rmcp::model::ResourceContents::BlobResourceContents {
|
|
uri: content.uri.clone(),
|
|
mime_type: Some(binary.mime_type.clone()),
|
|
blob: String::from_utf8_lossy(&binary.data).into_owned(),
|
|
meta: None,
|
|
}
|
|
}
|
|
None => {
|
|
// Default to text if no content type is specified
|
|
rmcp::model::ResourceContents::TextResourceContents {
|
|
uri: content.uri.clone(),
|
|
mime_type: None,
|
|
text: "".to_string(),
|
|
meta: None,
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
ReadMCPResourceResult::Success { resource_contents }
|
|
}
|
|
Some(api::read_mcp_resource_result::Result::Error(error)) => {
|
|
ReadMCPResourceResult::Error(error.message.clone())
|
|
}
|
|
None => ReadMCPResourceResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::ReadMCPResource(mcp_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::CallMcpTool(result)) => {
|
|
let mcp_tool_result = match &result.result {
|
|
Some(api::call_mcp_tool_result::Result::Success(success)) => {
|
|
let results = success
|
|
.results
|
|
.iter()
|
|
.map(|api_result| match &api_result.result {
|
|
Some(api::call_mcp_tool_result::success::result::Result::Text(
|
|
text,
|
|
)) => rmcp::model::Content::text(text.text.clone()),
|
|
Some(api::call_mcp_tool_result::success::result::Result::Image(
|
|
image,
|
|
)) => rmcp::model::Content::image(
|
|
String::from_utf8_lossy(&image.data).to_string(),
|
|
image.mime_type.clone(),
|
|
),
|
|
Some(api::call_mcp_tool_result::success::result::Result::Resource(
|
|
resource,
|
|
)) => match &resource.content_type {
|
|
Some(api::mcp_resource_content::ContentType::Text(text)) => {
|
|
rmcp::model::Content::resource(
|
|
rmcp::model::ResourceContents::text(
|
|
text.content.clone(),
|
|
resource.uri.clone(),
|
|
),
|
|
)
|
|
}
|
|
Some(api::mcp_resource_content::ContentType::Binary(binary)) => {
|
|
rmcp::model::Content::resource(
|
|
rmcp::model::ResourceContents::BlobResourceContents {
|
|
uri: resource.uri.clone(),
|
|
mime_type: Some(binary.mime_type.clone()),
|
|
blob: String::from_utf8_lossy(&binary.data).to_string(),
|
|
meta: None,
|
|
},
|
|
)
|
|
}
|
|
None => rmcp::model::Content::resource(
|
|
rmcp::model::ResourceContents::text(
|
|
String::new(),
|
|
resource.uri.clone(),
|
|
),
|
|
),
|
|
},
|
|
None => rmcp::model::Content::text(String::new()),
|
|
})
|
|
.collect();
|
|
|
|
let result = rmcp::model::CallToolResult::success(results);
|
|
|
|
CallMCPToolResult::Success { result }
|
|
}
|
|
Some(api::call_mcp_tool_result::Result::Error(error)) => {
|
|
CallMCPToolResult::Error(error.message.clone())
|
|
}
|
|
None => CallMCPToolResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::CallMCPTool(mcp_tool_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::ReadSkill(result)) => {
|
|
let read_skill_result = match &result.result {
|
|
Some(api::read_skill_result::Result::Success(success)) => {
|
|
if let Some(content) = &success.content {
|
|
let context = FileContext::from(content.clone());
|
|
ReadSkillResult::Success { content: context }
|
|
} else {
|
|
ReadSkillResult::Error("FileContent is None".to_string())
|
|
}
|
|
}
|
|
Some(api::read_skill_result::Result::Error(error)) => {
|
|
ReadSkillResult::Error(error.message.clone())
|
|
}
|
|
None => ReadSkillResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::ReadSkill(read_skill_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::SuggestNewConversation(result)) => {
|
|
let conversation_result = match &result.result {
|
|
Some(api::suggest_new_conversation_result::Result::Accepted(accepted)) => {
|
|
SuggestNewConversationResult::Accepted {
|
|
message_id: accepted.message_id.clone(),
|
|
}
|
|
}
|
|
Some(api::suggest_new_conversation_result::Result::Rejected(_)) => {
|
|
SuggestNewConversationResult::Rejected
|
|
}
|
|
None => SuggestNewConversationResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::SuggestNewConversation(conversation_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::SuggestPrompt(result)) => {
|
|
let prompt_result = match &result.result {
|
|
Some(api::suggest_prompt_result::Result::Accepted(_)) => {
|
|
// Find the accepted query from the original SuggestPrompt tool call
|
|
let query = tool_call_map.get(&tool_call_id)
|
|
.and_then(|tool_call| {
|
|
if let Some(api::message::tool_call::Tool::SuggestPrompt(suggest_prompt)) = &tool_call.tool {
|
|
match &suggest_prompt.display_mode {
|
|
Some(api::message::tool_call::suggest_prompt::DisplayMode::InlineQueryBanner(
|
|
inline_query_banner,
|
|
)) => Some(inline_query_banner.query.clone()),
|
|
_ => None,
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
SuggestPromptResult::Accepted { query }
|
|
}
|
|
_ => SuggestPromptResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::SuggestPrompt(prompt_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::OpenCodeReview(_)) => Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::OpenCodeReview,
|
|
},
|
|
context,
|
|
}),
|
|
Some(ToolCallResultType::InitProject(_)) => Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::InitProject,
|
|
},
|
|
context,
|
|
}),
|
|
Some(ToolCallResultType::ReadDocuments(result)) => {
|
|
let read_result = match &result.result {
|
|
Some(api::read_documents_result::Result::Success(success)) => {
|
|
let documents = success
|
|
.documents
|
|
.iter()
|
|
.filter_map(|doc| {
|
|
AIDocumentId::try_from(doc.document_id.clone())
|
|
.ok()
|
|
.map(|id| {
|
|
DocumentContext {
|
|
document_id: id,
|
|
// Version is a placeholder here - the actual current version
|
|
// will be determined when the document is accessed from AIDocumentModel
|
|
document_version: AIDocumentVersion(1),
|
|
content: doc.content.clone(),
|
|
line_ranges: vec![],
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
ReadDocumentsResult::Success { documents }
|
|
}
|
|
Some(api::read_documents_result::Result::Error(error)) => {
|
|
ReadDocumentsResult::Error(error.message.clone())
|
|
}
|
|
None => ReadDocumentsResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::ReadDocuments(read_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::EditDocuments(result)) => {
|
|
let edit_result = match &result.result {
|
|
Some(api::edit_documents_result::Result::Success(success)) => {
|
|
let updated_documents = success
|
|
.updated_documents
|
|
.iter()
|
|
.map(|doc| {
|
|
AIDocumentId::try_from(doc.document_id.clone())
|
|
.ok()
|
|
.map(|id| {
|
|
// Each edit produces a new version. Increment the
|
|
// tracked version for this document and use the result.
|
|
let entry = document_versions.entry(id).or_default();
|
|
let version = entry.next();
|
|
*entry = version;
|
|
DocumentContext {
|
|
document_id: id,
|
|
document_version: version,
|
|
content: doc.content.clone(),
|
|
line_ranges: vec![],
|
|
}
|
|
})
|
|
})
|
|
.collect::<Option<Vec<_>>>()
|
|
.unwrap_or_default();
|
|
|
|
EditDocumentsResult::Success { updated_documents }
|
|
}
|
|
Some(api::edit_documents_result::Result::Error(error)) => {
|
|
EditDocumentsResult::Error(error.message.clone())
|
|
}
|
|
None => EditDocumentsResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::EditDocuments(edit_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::CreateDocuments(result)) => {
|
|
let create_result = match &result.result {
|
|
Some(api::create_documents_result::Result::Success(success)) => {
|
|
let created_documents = success
|
|
.created_documents
|
|
.iter()
|
|
.map(|doc| {
|
|
AIDocumentId::try_from(doc.document_id.clone())
|
|
.ok()
|
|
.map(|id| {
|
|
// New documents always start at the default version.
|
|
let version = AIDocumentVersion::default();
|
|
document_versions.insert(id, version);
|
|
DocumentContext {
|
|
document_id: id,
|
|
document_version: version,
|
|
content: doc.content.clone(),
|
|
line_ranges: vec![],
|
|
}
|
|
})
|
|
})
|
|
.collect::<Option<Vec<_>>>()
|
|
.unwrap_or_default();
|
|
|
|
CreateDocumentsResult::Success { created_documents }
|
|
}
|
|
Some(api::create_documents_result::Result::Error(error)) => {
|
|
CreateDocumentsResult::Error(error.message.clone())
|
|
}
|
|
None => CreateDocumentsResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::CreateDocuments(create_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::ReadShellCommandOutput(result)) => {
|
|
let read_result = match &result.result {
|
|
Some(api::read_shell_command_output_result::Result::CommandFinished(finished)) => {
|
|
ReadShellCommandOutputResult::CommandFinished {
|
|
command: result.command.clone(),
|
|
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::read_shell_command_output_result::Result::LongRunningCommandSnapshot(
|
|
snapshot,
|
|
),
|
|
) => ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
|
command: result.command.clone(),
|
|
block_id: snapshot.command_id.clone().into(),
|
|
grid_contents: snapshot.output.clone(),
|
|
cursor: snapshot.cursor.clone(),
|
|
is_alt_screen_active: snapshot.is_alt_screen_active,
|
|
is_preempted: snapshot.is_preempted,
|
|
},
|
|
Some(api::read_shell_command_output_result::Result::Error(
|
|
api::ShellCommandError {
|
|
r#type: Some(api::shell_command_error::Type::CommandNotFound(())),
|
|
},
|
|
)) => ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
|
|
Some(api::read_shell_command_output_result::Result::Error(_)) | None => {
|
|
ReadShellCommandOutputResult::Cancelled
|
|
}
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::ReadShellCommandOutput(read_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::TransferShellCommandControlToUser(result)) => {
|
|
let transfer_result = match &result.result {
|
|
Some(
|
|
api::transfer_shell_command_control_to_user_result::Result::LongRunningCommandSnapshot(snapshot),
|
|
) => TransferShellCommandControlToUserResult::Snapshot {
|
|
block_id: snapshot.command_id.clone().into(),
|
|
grid_contents: snapshot.output.clone(),
|
|
cursor: snapshot.cursor.clone(),
|
|
is_alt_screen_active: snapshot.is_alt_screen_active,
|
|
is_preempted: snapshot.is_preempted,
|
|
},
|
|
Some(
|
|
api::transfer_shell_command_control_to_user_result::Result::CommandFinished(finished),
|
|
) => TransferShellCommandControlToUserResult::CommandFinished {
|
|
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 {
|
|
r#type: Some(api::shell_command_error::Type::CommandNotFound(())),
|
|
},
|
|
)) => TransferShellCommandControlToUserResult::Error(
|
|
ShellCommandError::BlockNotFound,
|
|
),
|
|
Some(api::transfer_shell_command_control_to_user_result::Result::Error(_))
|
|
| None => TransferShellCommandControlToUserResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::TransferShellCommandControlToUser(
|
|
transfer_result,
|
|
),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::InsertReviewComments(api_result)) => {
|
|
let res = AIAgentActionResultType::InsertReviewComments(match &api_result.result {
|
|
Some(api::insert_review_comments_result::Result::Success(_)) => {
|
|
InsertReviewCommentsResult::Success {
|
|
repo_path: api_result.repo_path.clone(),
|
|
}
|
|
}
|
|
Some(api::insert_review_comments_result::Result::Error(err)) => {
|
|
InsertReviewCommentsResult::Error {
|
|
repo_path: api_result.repo_path.clone(),
|
|
message: err.message.clone(),
|
|
}
|
|
}
|
|
None => InsertReviewCommentsResult::Cancelled,
|
|
});
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: res,
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
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));
|
|
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 {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::UseComputer(use_computer_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::RequestComputerUseResult(result)) => {
|
|
let request_result = match &result.result {
|
|
Some(api::request_computer_use_result::Result::Approved(approved)) => {
|
|
match (approved, convert_api_platform(approved.platform)) {
|
|
(
|
|
api::request_computer_use_result::Approved {
|
|
screen_dimensions: Some(screen_dimensions),
|
|
initial_screenshot: Some(initial_screenshot),
|
|
windows,
|
|
..
|
|
},
|
|
Some(platform),
|
|
) => RequestComputerUseResult::Approved {
|
|
screenshot: computer_use::Screenshot {
|
|
width: initial_screenshot.width as usize,
|
|
height: initial_screenshot.height as usize,
|
|
original_width: screen_dimensions.width_px as usize,
|
|
original_height: screen_dimensions.height_px as usize,
|
|
data: initial_screenshot.data.clone(),
|
|
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"
|
|
.to_string(),
|
|
),
|
|
}
|
|
}
|
|
Some(api::request_computer_use_result::Result::Rejected(_)) => {
|
|
RequestComputerUseResult::Cancelled
|
|
}
|
|
Some(api::request_computer_use_result::Result::Error(error)) => {
|
|
RequestComputerUseResult::Error(error.message.clone())
|
|
}
|
|
None => RequestComputerUseResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::RequestComputerUse(request_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::FetchConversation(result)) => {
|
|
let fetch_result = match &result.result {
|
|
Some(api::fetch_conversation_result::Result::Success(success)) => {
|
|
FetchConversationResult::Success {
|
|
directory_path: success.directory_path.clone(),
|
|
}
|
|
}
|
|
Some(api::fetch_conversation_result::Result::Error(error)) => {
|
|
FetchConversationResult::Error(error.message.clone())
|
|
}
|
|
None => FetchConversationResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::FetchConversation(fetch_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::Server(_)) => {
|
|
// Server results should not create exchanges - return None
|
|
None
|
|
}
|
|
Some(ToolCallResultType::Cancel(_)) => {
|
|
// Cancel results indicate the tool call was explicitly cancelled
|
|
// Look up the original tool call to determine the correct result type
|
|
create_cancelled_result_for_tool_call(task_id, &tool_call_id, tool_call_map, context)
|
|
}
|
|
Some(ToolCallResultType::Subagent(_)) => None,
|
|
Some(ToolCallResultType::StartAgent(result)) => {
|
|
let start_agent_result = match &result.result {
|
|
Some(api::start_agent_result::Result::Success(success)) => {
|
|
StartAgentResult::Success {
|
|
agent_id: success.agent_id.clone(),
|
|
version: StartAgentVersion::V1,
|
|
}
|
|
}
|
|
Some(api::start_agent_result::Result::Error(error)) => StartAgentResult::Error {
|
|
error: error.error.clone(),
|
|
version: StartAgentVersion::V1,
|
|
},
|
|
None => StartAgentResult::Cancelled {
|
|
version: StartAgentVersion::V1,
|
|
},
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::StartAgent(start_agent_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::StartAgentV2(result)) => {
|
|
let start_agent_result = match &result.result {
|
|
Some(api::start_agent_v2_result::Result::Success(success)) => {
|
|
StartAgentResult::Success {
|
|
agent_id: success.agent_id.clone(),
|
|
version: StartAgentVersion::V2,
|
|
}
|
|
}
|
|
Some(api::start_agent_v2_result::Result::Error(error)) => StartAgentResult::Error {
|
|
error: error.error.clone(),
|
|
version: StartAgentVersion::V2,
|
|
},
|
|
None => StartAgentResult::Cancelled {
|
|
version: StartAgentVersion::V2,
|
|
},
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::StartAgent(start_agent_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::AskUserQuestion(result)) => {
|
|
let ask_result = match &result.result {
|
|
Some(warp_multi_agent_api::ask_user_question_result::Result::Success(success)) => {
|
|
AskUserQuestionResult::Success {
|
|
answers: success
|
|
.answers
|
|
.iter()
|
|
.map(|a| match &a.answer {
|
|
Some(AskUserQuestionAnswer::MultipleChoice(mc)) => {
|
|
AskUserQuestionAnswerItem::Answered {
|
|
question_id: a.question_id.clone(),
|
|
selected_options: mc.selected_options.clone(),
|
|
other_text: mc.other_text.clone(),
|
|
}
|
|
}
|
|
Some(AskUserQuestionAnswer::Skipped(())) | None => {
|
|
AskUserQuestionAnswerItem::Skipped {
|
|
question_id: a.question_id.clone(),
|
|
}
|
|
}
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
Some(warp_multi_agent_api::ask_user_question_result::Result::Error(err)) => {
|
|
AskUserQuestionResult::Error(err.message.clone())
|
|
}
|
|
None => AskUserQuestionResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::AskUserQuestion(ask_result),
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
Some(ToolCallResultType::SendMessageToAgent(result)) => {
|
|
let send_message_result = match &result.result {
|
|
Some(api::send_message_to_agent_result::Result::Success(success)) => {
|
|
SendMessageToAgentResult::Success {
|
|
message_id: success.message_id.clone(),
|
|
}
|
|
}
|
|
Some(api::send_message_to_agent_result::Result::Error(error)) => {
|
|
SendMessageToAgentResult::Error(error.message.clone())
|
|
}
|
|
None => SendMessageToAgentResult::Cancelled,
|
|
};
|
|
|
|
Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::SendMessageToAgent(send_message_result),
|
|
},
|
|
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(..))
|
|
| None => {
|
|
log::warn!("No result present for tool call ID: {tool_call_id}");
|
|
None
|
|
}
|
|
Some(ToolCallResultType::WaitForEvents(_)) => None,
|
|
}
|
|
}
|
|
|
|
/// Create a cancelled result for a tool call based on the original tool call type
|
|
fn create_cancelled_result_for_tool_call(
|
|
task_id: &TaskId,
|
|
tool_call_id: &str,
|
|
tool_call_map: &HashMap<String, &api::message::ToolCall>,
|
|
context: Arc<[AIAgentContext]>,
|
|
) -> Option<AIAgentInput> {
|
|
use api::message::tool_call::Tool as ToolType;
|
|
|
|
let Some(original_tool_call) = tool_call_map.get(tool_call_id) else {
|
|
log::warn!("No original tool call found for cancelled tool call ID: {tool_call_id}");
|
|
// Default to RequestCommandOutput if we can't find the original tool call
|
|
let cancelled_result = RequestCommandOutputResult::CancelledBeforeExecution;
|
|
return Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.to_string().into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::RequestCommandOutput(cancelled_result),
|
|
},
|
|
context,
|
|
});
|
|
};
|
|
|
|
let Some(tool) = &original_tool_call.tool else {
|
|
log::warn!("No tool found in original tool call for ID: {tool_call_id}");
|
|
// Default to RequestCommandOutput if we can't find the tool type
|
|
let cancelled_result = RequestCommandOutputResult::CancelledBeforeExecution;
|
|
return Some(AIAgentInput::ActionResult {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.to_string().into(),
|
|
task_id: task_id.clone(),
|
|
result: AIAgentActionResultType::RequestCommandOutput(cancelled_result),
|
|
},
|
|
context,
|
|
});
|
|
};
|
|
|
|
let result_type = match tool {
|
|
ToolType::RunShellCommand(_) => AIAgentActionResultType::RequestCommandOutput(
|
|
RequestCommandOutputResult::CancelledBeforeExecution,
|
|
),
|
|
ToolType::WriteToLongRunningShellCommand(_) => {
|
|
AIAgentActionResultType::WriteToLongRunningShellCommand(
|
|
WriteToLongRunningShellCommandResult::Cancelled,
|
|
)
|
|
}
|
|
ToolType::ReadFiles(_) => AIAgentActionResultType::ReadFiles(ReadFilesResult::Cancelled),
|
|
ToolType::UploadFileArtifact(_) => {
|
|
AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Cancelled)
|
|
}
|
|
ToolType::SearchCodebase(_) => {
|
|
AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Cancelled)
|
|
}
|
|
ToolType::ApplyFileDiffs(_) => {
|
|
AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::Cancelled)
|
|
}
|
|
ToolType::Grep(_) => AIAgentActionResultType::Grep(GrepResult::Cancelled),
|
|
#[allow(deprecated)]
|
|
ToolType::FileGlob(_) => AIAgentActionResultType::FileGlob(FileGlobResult::Cancelled),
|
|
ToolType::FileGlobV2(_) => AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Cancelled),
|
|
ToolType::ReadMcpResource(_) => {
|
|
AIAgentActionResultType::ReadMCPResource(ReadMCPResourceResult::Cancelled)
|
|
}
|
|
ToolType::CallMcpTool(_) => {
|
|
AIAgentActionResultType::CallMCPTool(CallMCPToolResult::Cancelled)
|
|
}
|
|
ToolType::ReadSkill(_) => AIAgentActionResultType::ReadSkill(ReadSkillResult::Cancelled),
|
|
ToolType::SuggestNewConversation(_) => {
|
|
AIAgentActionResultType::SuggestNewConversation(SuggestNewConversationResult::Cancelled)
|
|
}
|
|
ToolType::SuggestPrompt(_) => {
|
|
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Cancelled)
|
|
}
|
|
ToolType::OpenCodeReview(_) => AIAgentActionResultType::OpenCodeReview,
|
|
ToolType::InsertReviewComments(_) => {
|
|
AIAgentActionResultType::InsertReviewComments(InsertReviewCommentsResult::Cancelled)
|
|
}
|
|
ToolType::InitProject(_) => AIAgentActionResultType::InitProject,
|
|
ToolType::ReadDocuments(_) => {
|
|
AIAgentActionResultType::ReadDocuments(ReadDocumentsResult::Cancelled)
|
|
}
|
|
ToolType::EditDocuments(_) => {
|
|
AIAgentActionResultType::EditDocuments(EditDocumentsResult::Cancelled)
|
|
}
|
|
ToolType::CreateDocuments(_) => {
|
|
AIAgentActionResultType::CreateDocuments(CreateDocumentsResult::Cancelled)
|
|
}
|
|
ToolType::ReadShellCommandOutput(_) => {
|
|
AIAgentActionResultType::ReadShellCommandOutput(ReadShellCommandOutputResult::Cancelled)
|
|
}
|
|
ToolType::TransferShellCommandControlToUser(_) => {
|
|
AIAgentActionResultType::TransferShellCommandControlToUser(
|
|
TransferShellCommandControlToUserResult::Cancelled,
|
|
)
|
|
}
|
|
ToolType::UseComputer(_) => {
|
|
AIAgentActionResultType::UseComputer(UseComputerResult::Cancelled)
|
|
}
|
|
ToolType::RequestComputerUse(_) => {
|
|
AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Cancelled)
|
|
}
|
|
ToolType::FetchConversation(_) => {
|
|
AIAgentActionResultType::FetchConversation(FetchConversationResult::Cancelled)
|
|
}
|
|
ToolType::Server(_) => {
|
|
return None;
|
|
}
|
|
ToolType::Subagent(_) => return None,
|
|
ToolType::StartAgent(_) => {
|
|
AIAgentActionResultType::StartAgent(StartAgentResult::Cancelled {
|
|
version: StartAgentVersion::V1,
|
|
})
|
|
}
|
|
ToolType::StartAgentV2(_) => {
|
|
AIAgentActionResultType::StartAgent(StartAgentResult::Cancelled {
|
|
version: StartAgentVersion::V2,
|
|
})
|
|
}
|
|
ToolType::AskUserQuestion(_) => {
|
|
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
|
|
}
|
|
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 {
|
|
result: AIAgentActionResult {
|
|
id: tool_call_id.to_string().into(),
|
|
task_id: task_id.clone(),
|
|
result: result_type,
|
|
},
|
|
context,
|
|
})
|
|
}
|
|
|
|
fn create_exchange_from_messages(
|
|
inputs: &[AIAgentInput],
|
|
outputs: &[AIAgentOutputMessage],
|
|
is_output_tool_call_canceled: bool,
|
|
message_ids: &HashSet<String>,
|
|
message_map: &HashMap<&str, &api::Message>,
|
|
server_output_id: Option<&str>,
|
|
) -> Option<AIAgentExchange> {
|
|
// Allow exchanges with only outputs (e.g., when returning from a subtask).
|
|
if inputs.is_empty() && outputs.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let exchange_id = AIAgentExchangeId::new();
|
|
|
|
// get the exchange's start time from the latest input's context
|
|
let start_time = inputs
|
|
.last()
|
|
.and_then(|input| input.context())
|
|
.and_then(|contexts| {
|
|
contexts.iter().find_map(|context| match context {
|
|
AIAgentContext::CurrentTime { current_time } => Some(*current_time),
|
|
_ => None,
|
|
})
|
|
})
|
|
// Fall back to any timestamp from the messages in this exchange
|
|
.or_else(|| {
|
|
message_ids.iter().find_map(|message_id| {
|
|
message_map.get(message_id.as_str()).and_then(|message| {
|
|
message.timestamp.as_ref().map(|timestamp| {
|
|
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Get the exchange's finish time from the last message timestamp in this exchange.
|
|
let finish_time = message_ids
|
|
.iter()
|
|
.filter_map(|message_id| {
|
|
message_map.get(message_id.as_str()).and_then(|message| {
|
|
message.timestamp.as_ref().map(|timestamp| {
|
|
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
|
})
|
|
})
|
|
})
|
|
.max();
|
|
|
|
// Compute time to first token from the first non-input message in this exchange,
|
|
// reusing the same logic we expose for restored/forked conversations.
|
|
let time_to_first_token_ms = compute_time_to_first_token_ms_from_messages(
|
|
start_time,
|
|
message_ids
|
|
.iter()
|
|
.filter_map(|message_id| message_map.get(message_id.as_str()).copied()),
|
|
);
|
|
|
|
// Collect all citations from the output messages.
|
|
let mut citations = Vec::new();
|
|
for output in outputs {
|
|
citations.extend(output.citations.clone());
|
|
}
|
|
let model_used = message_ids
|
|
.iter()
|
|
.filter_map(|message_id| message_map.get(message_id.as_str()))
|
|
.find_map(|message| {
|
|
message.message.as_ref().and_then(|message| match message {
|
|
api::message::Message::ModelUsed(model_used) => Some(model_used),
|
|
_ => None,
|
|
})
|
|
});
|
|
|
|
// Create AIAgentOutput from the output messages
|
|
let ai_output = AIAgentOutput {
|
|
messages: outputs.to_vec(),
|
|
citations,
|
|
api_metadata_bytes: None,
|
|
server_output_id: server_output_id.map(|id| ServerOutputId::new(id.to_owned())),
|
|
suggestions: None,
|
|
telemetry_events: vec![],
|
|
model_info: model_used.map(|model| OutputModelInfo {
|
|
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,
|
|
};
|
|
|
|
// There is a special case where an exchange consists of only ActionResults with no outputs
|
|
// (i.e. if a passive code diff is accepted and the agent doesn't follow up, which it shouldn't).
|
|
// In this case, we should not mark the exchange as cancelled.
|
|
let last_input_is_action_result = inputs
|
|
.last()
|
|
.map(|input| matches!(input, AIAgentInput::ActionResult { .. }))
|
|
.unwrap_or(false);
|
|
|
|
// Create exchange with finished status
|
|
let output_status = if outputs.is_empty() && !last_input_is_action_result {
|
|
AIAgentOutputStatus::Finished {
|
|
finished_output: FinishedAIAgentOutput::Cancelled {
|
|
output: None,
|
|
reason: CancellationReason::ManuallyCancelled,
|
|
},
|
|
}
|
|
} else if is_output_tool_call_canceled {
|
|
AIAgentOutputStatus::Finished {
|
|
finished_output: FinishedAIAgentOutput::Cancelled {
|
|
output: Some(Shared::new(ai_output)),
|
|
reason: CancellationReason::ManuallyCancelled,
|
|
},
|
|
}
|
|
} else {
|
|
AIAgentOutputStatus::Finished {
|
|
finished_output: FinishedAIAgentOutput::Success {
|
|
output: Shared::new(ai_output),
|
|
},
|
|
}
|
|
};
|
|
|
|
// Extract working directory from the first input that has a Directory context
|
|
let working_directory = inputs.iter().find_map(|input| {
|
|
input.context().and_then(|contexts| {
|
|
contexts.iter().find_map(|context| {
|
|
if let AIAgentContext::Directory { pwd, .. } = context {
|
|
pwd.clone()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
});
|
|
|
|
// Use a default LLM ID for restored exchanges
|
|
let default_model_id = LLMId::from("auto");
|
|
|
|
Some(AIAgentExchange {
|
|
id: exchange_id,
|
|
input: inputs.to_vec(),
|
|
output_status,
|
|
start_time,
|
|
finish_time,
|
|
time_to_first_token_ms,
|
|
working_directory,
|
|
model_id: default_model_id.clone(),
|
|
request_cost: None,
|
|
coding_model_id: default_model_id.clone(),
|
|
cli_agent_model_id: default_model_id.clone(),
|
|
computer_use_model_id: default_model_id,
|
|
response_initiator: None,
|
|
added_message_ids: message_ids.iter().map(|s| s.clone().into()).collect(),
|
|
})
|
|
}
|
|
|
|
/// Compute the time to first token for an exchange from a sequence of server messages.
|
|
pub(crate) fn compute_time_to_first_token_ms_from_messages<'a, I>(
|
|
start_time: DateTime<Local>,
|
|
messages: I,
|
|
) -> Option<i64>
|
|
where
|
|
I: Iterator<Item = &'a api::Message>,
|
|
{
|
|
let first_output_time = messages
|
|
.filter_map(|message| {
|
|
let msg = message.message.as_ref()?;
|
|
match msg {
|
|
// Messages treated as inputs in create_exchange_from_messages
|
|
api::message::Message::UserQuery(_)
|
|
| api::message::Message::SystemQuery(_)
|
|
| api::message::Message::ToolCallResult(_)
|
|
| api::message::Message::UpdateTodos(_)
|
|
| api::message::Message::MessagesReceivedFromAgents(_)
|
|
| api::message::Message::EventsFromAgents(_)
|
|
| api::message::Message::PassiveSuggestionResult(_) => None,
|
|
// Anything else is considered agent/stream activity we want to measure
|
|
api::message::Message::AgentOutput(_)
|
|
| api::message::Message::AgentReasoning(_)
|
|
| api::message::Message::Summarization(_)
|
|
| api::message::Message::ToolCall(_)
|
|
| api::message::Message::ServerEvent(_)
|
|
| api::message::Message::UpdateReviewComments(_)
|
|
| api::message::Message::CodeReview(_)
|
|
| api::message::Message::WebSearch(_)
|
|
| api::message::Message::WebFetch(_)
|
|
| api::message::Message::DebugOutput(_)
|
|
| api::message::Message::ArtifactEvent(_)
|
|
| api::message::Message::InvokeSkill(_)
|
|
| api::message::Message::ModelUsed(_)
|
|
| api::message::Message::OrchestrationConfigSnapshot(_) => {
|
|
message.timestamp.as_ref().map(|timestamp| {
|
|
proto_timestamp_to_local_datetime(timestamp.seconds, timestamp.nanos)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
.min()?;
|
|
|
|
if first_output_time < start_time {
|
|
return None;
|
|
}
|
|
|
|
Some(
|
|
first_output_time
|
|
.signed_duration_since(start_time)
|
|
.num_milliseconds()
|
|
.max(0),
|
|
)
|
|
}
|
|
|
|
impl From<api::ExecutedShellCommand> for BlockContext {
|
|
fn from(cmd: api::ExecutedShellCommand) -> Self {
|
|
BlockContext {
|
|
id: cmd.command_id.into(),
|
|
index: BlockIndex::from(0),
|
|
command: cmd.command,
|
|
output: cmd.output,
|
|
exit_code: ExitCode::from(cmd.exit_code),
|
|
is_auto_attached: cmd.is_auto_attached,
|
|
started_ts: cmd
|
|
.started_ts
|
|
.as_ref()
|
|
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
|
|
finished_ts: cmd
|
|
.finished_ts
|
|
.as_ref()
|
|
.map(|ts| proto_timestamp_to_local_datetime(ts.seconds, ts.nanos)),
|
|
pwd: None,
|
|
shell: None,
|
|
username: None,
|
|
hostname: None,
|
|
git_branch: None,
|
|
os: None,
|
|
session_id: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Converts a persisted `PassiveSuggestionResult` message back to an
|
|
/// [`AIAgentInput::PassiveSuggestionResult`] so the trigger block information
|
|
/// is available after conversation restoration.
|
|
fn convert_passive_suggestion_result_to_input(
|
|
passive_result: &api::message::PassiveSuggestionResult,
|
|
) -> Option<AIAgentInput> {
|
|
let api_result = passive_result.result.as_ref()?;
|
|
|
|
let trigger = match &api_result.trigger {
|
|
Some(api::passive_suggestion_result_type::Trigger::ExecutedShellCommand(cmd)) => {
|
|
PassiveSuggestionTrigger::ShellCommandCompleted(ShellCommandCompletedTrigger {
|
|
executed_shell_command: Box::new(cmd.clone().into()),
|
|
// Relevant files are not persisted in the proto message.
|
|
relevant_files: vec![],
|
|
})
|
|
}
|
|
Some(api::passive_suggestion_result_type::Trigger::AgentResponseCompleted(_)) => {
|
|
// The exchange_id is not stored in the proto; use a placeholder.
|
|
PassiveSuggestionTrigger::AgentResponseCompleted {
|
|
exchange_id: AIAgentExchangeId::default(),
|
|
}
|
|
}
|
|
None => return None,
|
|
};
|
|
|
|
let suggestion = match &api_result.suggestion {
|
|
Some(api::passive_suggestion_result_type::Suggestion::Prompt(p)) => {
|
|
PassiveSuggestionResultType::Prompt {
|
|
prompt: p.prompt.clone(),
|
|
}
|
|
}
|
|
Some(api::passive_suggestion_result_type::Suggestion::CodeDiff(cd)) => {
|
|
PassiveSuggestionResultType::CodeDiff {
|
|
diffs: cd
|
|
.diffs
|
|
.iter()
|
|
.map(|d| PassiveCodeDiffEntry {
|
|
file_path: d.file_path.clone(),
|
|
search: d.search.clone(),
|
|
replace: d.replace.clone(),
|
|
})
|
|
.collect(),
|
|
summary: cd.summary.clone(),
|
|
accepted: cd.accepted,
|
|
}
|
|
}
|
|
None => return None,
|
|
};
|
|
|
|
let context = convert_input_context(passive_result.context.as_ref());
|
|
|
|
Some(AIAgentInput::PassiveSuggestionResult {
|
|
trigger: Some(trigger),
|
|
suggestion,
|
|
context,
|
|
})
|
|
}
|
|
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
|
|
.timestamp_opt(seconds, nanos)
|
|
.single()
|
|
.unwrap_or_else(|| Local.timestamp_opt(0, 0).unwrap())
|
|
}
|
|
|
|
impl From<String> for crate::ai::agent::MessageId {
|
|
fn from(s: String) -> Self {
|
|
crate::ai::agent::MessageId(s)
|
|
}
|
|
}
|
|
|
|
fn convert_api_platform(platform: i32) -> Option<computer_use::Platform> {
|
|
use api::request_computer_use_result::approved::Platform;
|
|
match Platform::try_from(platform) {
|
|
Ok(Platform::Macos) => Some(computer_use::Platform::Mac),
|
|
Ok(Platform::Windows) => Some(computer_use::Platform::Windows),
|
|
Ok(Platform::LinuxX11) => Some(computer_use::Platform::LinuxX11),
|
|
Ok(Platform::LinuxWayland) => Some(computer_use::Platform::LinuxWayland),
|
|
Err(_) => {
|
|
log::warn!("Unknown platform value: {platform}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|