Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
use std::{collections::HashMap, path::Path, sync::Arc};
|
||||
|
||||
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
|
||||
use chrono::Local;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAttachment, AIAgentContext,
|
||||
DocumentContentAttachmentSource, DriveObjectPayload,
|
||||
},
|
||||
block_context::BlockContext,
|
||||
blocklist::BlocklistAIContextModel,
|
||||
document::ai_document_model::{AIDocumentId, AIDocumentModel},
|
||||
facts::CloudAIFactModel,
|
||||
skills::list_skills_if_changed,
|
||||
},
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{CloudStringObject, GenericStringObjectId},
|
||||
persistence::CloudModel,
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType,
|
||||
},
|
||||
terminal::{
|
||||
model::{block::BlockId, session::active_session::ActiveSession},
|
||||
TerminalView,
|
||||
},
|
||||
};
|
||||
use warp_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
|
||||
|
||||
lazy_static! {
|
||||
// Regex to match <block:[block_id]> patterns
|
||||
pub static ref BLOCK_CONTEXT_ATTACHMENT_REGEX: Regex = Regex::new(r"<block:([^>]+)>")
|
||||
.expect("Block context attachment regex should be parsed");
|
||||
// Regex to match warp drive objects inserted via at-context. Ex: <notebook:[workflow_id]>
|
||||
pub static ref DRIVE_OBJECT_ATTACHMENT_REGEX: Regex = Regex::new(r"<(workflow|notebook|plan|rule):([^>]+)>")
|
||||
.expect("Drive object attachment regex should be parsed");
|
||||
// Regex to match <change:filename:line_start-line_end> patterns
|
||||
pub static ref DIFF_HUNK_ATTACHMENT_REGEX: Regex = Regex::new(r"<change:([^>]+)>")
|
||||
.expect("Diff hunk attachment regex should be parsed");
|
||||
}
|
||||
|
||||
// Returns the context to be attached to the AIAgentInput sent in a request.
|
||||
// If `is_user_query` is true, includes selected blocks, text, and images from the context model.
|
||||
// Always includes base context like current time, execution environment, and codebase info.
|
||||
pub(super) fn input_context_for_request(
|
||||
is_user_query: bool,
|
||||
context_model: &BlocklistAIContextModel,
|
||||
active_session: &ActiveSession,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
additional_context: Vec<AIAgentContext>,
|
||||
app: &AppContext,
|
||||
) -> Arc<[AIAgentContext]> {
|
||||
let mut context = context_model.pending_context(app, is_user_query);
|
||||
|
||||
context.push(AIAgentContext::CurrentTime {
|
||||
current_time: Local::now(),
|
||||
});
|
||||
|
||||
if let Some(env) = active_session.ai_execution_environment(app) {
|
||||
context.push(AIAgentContext::ExecutionEnvironment(env));
|
||||
}
|
||||
|
||||
if FeatureFlag::FullSourceCodeEmbedding.is_enabled()
|
||||
&& FeatureFlag::CrossRepoContext.is_enabled()
|
||||
{
|
||||
for (codebase_path, status) in
|
||||
CodebaseIndexManager::as_ref(app).get_codebase_index_statuses(app)
|
||||
{
|
||||
// TODO(daniel): We should figure out a mechanism for handling stale codebases.
|
||||
if status.has_synced_version() {
|
||||
// For now, we pass the name of the directory as the name of the
|
||||
// codebase.
|
||||
let codebase_name = codebase_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_default();
|
||||
|
||||
context.push(AIAgentContext::Codebase {
|
||||
name: codebase_name.into(),
|
||||
path: codebase_path.to_string_lossy().into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::ListSkills.is_enabled() {
|
||||
let skills = list_skills_if_changed(
|
||||
active_session.current_working_directory().map(Path::new),
|
||||
conversation_id,
|
||||
app,
|
||||
);
|
||||
|
||||
if let Some(skills) = skills {
|
||||
context.push(AIAgentContext::Skills { skills });
|
||||
}
|
||||
}
|
||||
|
||||
context.extend(additional_context);
|
||||
|
||||
context.into()
|
||||
}
|
||||
|
||||
/// Parses context reference strings like <block:123> from the user query and returns
|
||||
/// a map of reference strings to AIAgentAttachment objects.
|
||||
///
|
||||
/// This searches across ALL TerminalModels, not just the active session, to find
|
||||
/// the requested blocks.
|
||||
pub(super) fn parse_context_attachments(
|
||||
query: &str,
|
||||
context_model: &BlocklistAIContextModel,
|
||||
ctx: &AppContext,
|
||||
) -> HashMap<String, AIAgentAttachment> {
|
||||
let mut referenced_attachments = HashMap::new();
|
||||
|
||||
// Parse block attachments
|
||||
for capture in BLOCK_CONTEXT_ATTACHMENT_REGEX.captures_iter(query) {
|
||||
if let (Some(full_match), Some(block_id_match)) = (capture.get(0), capture.get(1)) {
|
||||
let reference_string = full_match.as_str().to_string();
|
||||
let block_id_str = block_id_match.as_str();
|
||||
|
||||
let block_id = BlockId::from(block_id_str.to_string());
|
||||
|
||||
// Search across ALL TerminalModels to find the block
|
||||
if let Some(attachment) = find_block_attachment_in_all_terminals(&block_id, ctx) {
|
||||
referenced_attachments.insert(reference_string, attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse drive object attachments (notebooks, workflows, etc)
|
||||
for capture in DRIVE_OBJECT_ATTACHMENT_REGEX.captures_iter(query) {
|
||||
if let (Some(full_match), Some(object_type_match), Some(object_id_match)) =
|
||||
(capture.get(0), capture.get(1), capture.get(2))
|
||||
{
|
||||
let reference_string = full_match.as_str().to_string();
|
||||
let object_type_str = object_type_match.as_str();
|
||||
let id_str = object_id_match.as_str();
|
||||
|
||||
if object_type_str == "plan" {
|
||||
// For plans, id_str is ai_document_id
|
||||
let ai_doc_id = match AIDocumentId::try_from(id_str) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
log::warn!("Invalid ai_document_id in plan reference: {id_str}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer live editor content from AIDocumentModel (picks up unsaved user edits).
|
||||
// Fall back to the synced CloudModel notebook if the document isn't loaded in
|
||||
// the current session.
|
||||
let content = AIDocumentModel::as_ref(ctx)
|
||||
.get_document_content(&ai_doc_id, ctx)
|
||||
.or_else(|| {
|
||||
CloudModel::as_ref(ctx)
|
||||
.get_all_active_notebooks()
|
||||
.find(|nb| nb.model().ai_document_id.as_ref() == Some(&ai_doc_id))
|
||||
.map(|nb| nb.model().data.clone())
|
||||
});
|
||||
|
||||
if let Some(content) = content {
|
||||
let attachment = AIAgentAttachment::DocumentContent {
|
||||
document_id: id_str.to_string(),
|
||||
content,
|
||||
source: DocumentContentAttachmentSource::UserAttached,
|
||||
line_range: None,
|
||||
};
|
||||
referenced_attachments.insert(reference_string, attachment);
|
||||
} else {
|
||||
log::warn!("Plan not found for ai_document_id: {ai_doc_id}");
|
||||
}
|
||||
} else {
|
||||
let object_type = match object_type_str {
|
||||
"workflow" => ObjectType::Workflow,
|
||||
"notebook" => ObjectType::Notebook,
|
||||
"rule" => ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::AIFact,
|
||||
)),
|
||||
_ => continue, // Skip unknown object types
|
||||
};
|
||||
|
||||
// Try to get the object data from CloudModel
|
||||
let payload = get_object_attachment_payload(id_str, object_type, ctx);
|
||||
|
||||
// Create a DriveObject attachment with the object UID and payload
|
||||
let attachment = AIAgentAttachment::DriveObject {
|
||||
uid: id_str.to_string(),
|
||||
payload,
|
||||
};
|
||||
referenced_attachments.insert(reference_string, attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse diff hunk attachments
|
||||
for capture in DIFF_HUNK_ATTACHMENT_REGEX.captures_iter(query) {
|
||||
if let (Some(full_match), Some(diff_hunk_match)) = (capture.get(0), capture.get(1)) {
|
||||
let reference_string = full_match.as_str().to_string();
|
||||
let diff_hunk_key = diff_hunk_match.as_str();
|
||||
|
||||
// Check if we have a stored diff hunk attachment for this key
|
||||
if let Some(attachment) = context_model.get_diff_hunk_attachment(diff_hunk_key) {
|
||||
referenced_attachments.insert(reference_string, attachment.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add pending file attachments as FilePathReference.
|
||||
// Duplicate basenames get a (1), (2), ... suffix to avoid collisions,
|
||||
// matching the pattern in build_file_attachment_map.
|
||||
for file in context_model.pending_files().iter() {
|
||||
let attachment = AIAgentAttachment::FilePathReference {
|
||||
file_id: uuid::Uuid::new_v4().to_string(),
|
||||
file_name: file.file_name.clone(),
|
||||
file_path: file.file_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let mut key = file.file_name.clone();
|
||||
if referenced_attachments.contains_key(&key) {
|
||||
let mut suffix = 1;
|
||||
loop {
|
||||
key = format!("{} ({suffix})", file.file_name);
|
||||
if !referenced_attachments.contains_key(&key) {
|
||||
break;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
referenced_attachments.insert(key, attachment);
|
||||
}
|
||||
|
||||
// Add pending AI document as attachment if present
|
||||
if let Some(document_id) = context_model.pending_document_id() {
|
||||
if let Some(content) = AIDocumentModel::as_ref(ctx).get_document_content(&document_id, ctx)
|
||||
{
|
||||
let document_id_str = document_id.to_string();
|
||||
let attachment = AIAgentAttachment::DocumentContent {
|
||||
document_id: document_id_str.clone(),
|
||||
content,
|
||||
source: DocumentContentAttachmentSource::PlanEdited,
|
||||
line_range: None,
|
||||
};
|
||||
// Use the document ID as the reference key
|
||||
referenced_attachments.insert(document_id_str, attachment);
|
||||
}
|
||||
}
|
||||
|
||||
referenced_attachments
|
||||
}
|
||||
|
||||
/// Searches for a block across all terminal models in the application.
|
||||
/// Returns an AIAgentAttachment if the block is found.
|
||||
fn find_block_attachment_in_all_terminals(
|
||||
block_id: &BlockId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<AIAgentAttachment> {
|
||||
// Iterate over all window IDs to search across all terminal views
|
||||
for window_id in ctx.window_ids() {
|
||||
// Try to get all terminal views for this window
|
||||
if let Some(terminal_views) = ctx.views_of_type::<TerminalView>(window_id) {
|
||||
for terminal_view_handle in terminal_views {
|
||||
let terminal_view = terminal_view_handle.as_ref(ctx);
|
||||
let terminal_model = terminal_view.model.lock();
|
||||
let block_list = terminal_model.block_list();
|
||||
|
||||
if let Some(block) = block_list.block_with_id(block_id) {
|
||||
// Create an AIAgentAttachment for the block
|
||||
return Some(AIAgentAttachment::Block(BlockContext {
|
||||
id: block.id().clone(),
|
||||
index: block.index(),
|
||||
command: block.command_to_string(),
|
||||
output: block.output_to_string(),
|
||||
exit_code: block.exit_code(),
|
||||
is_auto_attached: false,
|
||||
started_ts: block.start_ts().cloned(),
|
||||
finished_ts: block.completed_ts().cloned(),
|
||||
pwd: None,
|
||||
shell: None,
|
||||
username: None,
|
||||
hostname: None,
|
||||
git_branch: None,
|
||||
os: None,
|
||||
session_id: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Gets the object payload from CloudModel for the given UID and object type.
|
||||
/// Returns None if the object is not found.
|
||||
fn get_object_attachment_payload(
|
||||
uid: &str,
|
||||
object_type: ObjectType,
|
||||
ctx: &AppContext,
|
||||
) -> Option<DriveObjectPayload> {
|
||||
match object_type {
|
||||
ObjectType::Workflow => CloudModel::as_ref(ctx)
|
||||
.get_workflow_by_uid(uid)
|
||||
.map(|workflow| {
|
||||
let workflow_data = &workflow.model().data;
|
||||
DriveObjectPayload::Workflow {
|
||||
name: workflow_data.name().to_string(),
|
||||
description: workflow_data.description().cloned().unwrap_or_default(),
|
||||
command: workflow_data.content().to_string(),
|
||||
}
|
||||
}),
|
||||
ObjectType::Notebook => CloudModel::as_ref(ctx)
|
||||
.get_notebook_by_uid(uid)
|
||||
.map(|notebook| DriveObjectPayload::Notebook {
|
||||
title: notebook.model().title.clone(),
|
||||
content: notebook.model().data.clone(),
|
||||
}),
|
||||
ObjectType::GenericStringObject(_) => {
|
||||
// For generic string objects, we only support AI facts (rules) for now
|
||||
CloudModel::as_ref(ctx)
|
||||
.get_by_uid(&uid.to_string())
|
||||
.and_then(|object| {
|
||||
if let Some(ai_fact) = object.as_any().downcast_ref::<GenericCloudObject<GenericStringObjectId, CloudAIFactModel>>() {
|
||||
let string_object = ai_fact as &dyn CloudStringObject;
|
||||
// Convert the format to GraphQL format since that's what the server expects
|
||||
let graphql_format: GraphQLFormat =
|
||||
string_object.generic_string_object_format().into();
|
||||
Some(DriveObjectPayload::GenericStringObject {
|
||||
payload: string_object.serialized().model_as_str().to_string(),
|
||||
object_type: graphql_format.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => None, // Other object types not supported for drive object attachments
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::agent::{conversation::AIConversationId, CancellationReason},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
|
||||
use super::{
|
||||
response_stream::{ResponseStream, ResponseStreamId},
|
||||
BlocklistAIController,
|
||||
};
|
||||
|
||||
pub(super) struct PendingResponseStreams {
|
||||
streams: HashMap<ResponseStreamId, ModelHandle<ResponseStream>>,
|
||||
}
|
||||
|
||||
impl PendingResponseStreams {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
streams: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_active_stream_for_conversation(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(&conversation_id) else {
|
||||
return false;
|
||||
};
|
||||
self.streams
|
||||
.keys()
|
||||
.any(|stream_id| conversation.is_processing_response_stream(stream_id))
|
||||
}
|
||||
|
||||
pub fn register_new_stream(
|
||||
&mut self,
|
||||
stream_id: ResponseStreamId,
|
||||
conversation_id: AIConversationId,
|
||||
stream: ModelHandle<ResponseStream>,
|
||||
reason: CancellationReason,
|
||||
ctx: &mut ModelContext<BlocklistAIController>,
|
||||
) {
|
||||
self.try_cancel_streams_for_conversation(conversation_id, reason, ctx);
|
||||
self.streams.insert(stream_id, stream);
|
||||
}
|
||||
|
||||
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
|
||||
self.streams.remove(stream_id);
|
||||
}
|
||||
|
||||
pub fn try_cancel_stream(
|
||||
&mut self,
|
||||
stream_id: &ResponseStreamId,
|
||||
reason: CancellationReason,
|
||||
ctx: &mut ModelContext<BlocklistAIController>,
|
||||
) -> bool {
|
||||
if let Some(stream) = self.streams.remove(stream_id) {
|
||||
// Look up which conversation owns this stream
|
||||
let Some(conversation_id) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(stream_id)
|
||||
else {
|
||||
log::warn!("Could not find conversation for stream {stream_id:?}, cannot cancel");
|
||||
return false;
|
||||
};
|
||||
|
||||
stream.update(ctx, |stream, ctx| {
|
||||
stream.cancel(reason, conversation_id, ctx)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Cancels all streams for the given conversation
|
||||
pub fn try_cancel_streams_for_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
reason: CancellationReason,
|
||||
ctx: &mut ModelContext<BlocklistAIController>,
|
||||
) -> bool {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history_model.conversation(&conversation_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let streams_to_cancel = self
|
||||
.streams
|
||||
.extract_if(|stream_id, _| conversation.is_processing_response_stream(stream_id))
|
||||
.map(|(_, stream)| stream)
|
||||
.collect_vec();
|
||||
|
||||
if streams_to_cancel.is_empty() {
|
||||
false
|
||||
} else {
|
||||
for response_stream in streams_to_cancel.into_iter() {
|
||||
response_stream.update(ctx, |stream, ctx| {
|
||||
stream.cancel(reason, conversation_id, ctx)
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use futures::channel::oneshot;
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::agent::{
|
||||
api::{self, generate_multi_agent_output, ConvertToAPITypeError},
|
||||
conversation::AIConversationId,
|
||||
AIIdentifiers, CancellationReason,
|
||||
},
|
||||
network::NetworkStatus,
|
||||
report_error, send_telemetry_from_ctx,
|
||||
server::server_api::ServerApiProvider,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ResponseStreamId(String);
|
||||
|
||||
impl ResponseStreamId {
|
||||
pub fn for_shared_session(init_event: &response_event::StreamInit) -> Self {
|
||||
// Make the stream ID unique per viewing by appending a local UUID
|
||||
// This prevents collisions when replaying the same conversation multiple times
|
||||
// (either on close-and-reopen or when viewing the same shared session from multiple terminals)
|
||||
Self(format!("{}-{}", init_event.request_id, Uuid::new_v4()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test() -> Self {
|
||||
Self(Uuid::new_v4().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Model wrapping an agent API response stream.
|
||||
///
|
||||
/// Emits events when the output corresponding to the stream is updated, typically after receiving
|
||||
/// each response chunk.
|
||||
///
|
||||
/// Handles retries internally - retries are only attempted if no ClientActions events have been
|
||||
/// received yet, ensuring we don't retry after the AI has started executing actions.
|
||||
pub struct ResponseStream {
|
||||
id: ResponseStreamId,
|
||||
params: api::RequestParams,
|
||||
retry_count: usize,
|
||||
start_time: DateTime<Local>,
|
||||
time_to_latest_event: TimeDelta,
|
||||
cancellation_tx: Option<oneshot::Sender<()>>,
|
||||
/// Store the original error for telemetry when retries succeed
|
||||
original_error: Option<String>,
|
||||
/// Track whether we've received any client actions
|
||||
/// If true, we cannot retry on subsequent errors since actions may have been executed
|
||||
has_received_client_actions: bool,
|
||||
/// AI identifiers for telemetry emission
|
||||
ai_identifiers: AIIdentifiers,
|
||||
|
||||
/// Whether this request can attempt to resume the conversation on error.
|
||||
/// This is true for all requests except those that are themselves the result of a resume
|
||||
/// triggered by a previous error.
|
||||
can_attempt_resume_on_error: bool,
|
||||
|
||||
/// Whether we should attempt to resume the conversation after the stream finishes.
|
||||
///
|
||||
/// This is set when we receive a retryable error after client actions have been received
|
||||
/// and `can_attempt_resume_on_error` is true.
|
||||
should_resume_conversation_after_stream_finished: bool,
|
||||
|
||||
/// Unique, internal id for the current request.
|
||||
///
|
||||
/// This ensures that the model never emits events for a request that was already cancelled (or
|
||||
/// retried) and is still receiving lagging events.
|
||||
///
|
||||
/// Note this is unique compared to `id`; this is unique across retry requests while the response
|
||||
/// stream id remains stable.
|
||||
current_request_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ResponseStream {
|
||||
pub fn new(
|
||||
params: api::RequestParams,
|
||||
ai_identifiers: AIIdentifiers,
|
||||
can_attempt_resume_on_error: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let (cancellation_tx, cancellation_rx) = oneshot::channel();
|
||||
let start_time = Local::now();
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let params_clone = params.clone();
|
||||
let _ =
|
||||
ctx.spawn(
|
||||
async move {
|
||||
generate_multi_agent_output(server_api, params_clone, cancellation_rx).await
|
||||
},
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
Self {
|
||||
id: ResponseStreamId(Uuid::new_v4().to_string()),
|
||||
params: params.clone(),
|
||||
start_time,
|
||||
time_to_latest_event: TimeDelta::seconds(0),
|
||||
cancellation_tx: Some(cancellation_tx),
|
||||
retry_count: 0,
|
||||
original_error: None,
|
||||
has_received_client_actions: false,
|
||||
ai_identifiers,
|
||||
can_attempt_resume_on_error,
|
||||
should_resume_conversation_after_stream_finished: false,
|
||||
current_request_id: Some(request_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &ResponseStreamId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns true if we should attempt to resume the conversation after the stream finishes.
|
||||
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
|
||||
self.should_resume_conversation_after_stream_finished
|
||||
}
|
||||
|
||||
/// Helper function to emit AgentModeError telemetry for error that is retryable (not user visible).
|
||||
fn emit_retryable_agent_mode_error_telemetry(
|
||||
&self,
|
||||
error: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
send_telemetry_from_ctx!(
|
||||
crate::TelemetryEvent::AgentModeError {
|
||||
identifiers: self.ai_identifiers.clone(),
|
||||
error,
|
||||
is_user_visible: false,
|
||||
will_attempt_to_resume: false,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn retry(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.retry_count += 1;
|
||||
self.has_received_client_actions = false; // Reset for the new attempt
|
||||
|
||||
let (cancellation_tx, cancellation_rx) = oneshot::channel();
|
||||
if let Some(old_cancellation_tx) = self.cancellation_tx.take() {
|
||||
let _ = old_cancellation_tx.send(());
|
||||
}
|
||||
self.cancellation_tx = Some(cancellation_tx);
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
self.current_request_id = Some(request_id);
|
||||
let params = self.params.clone();
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let _ = ctx.spawn(
|
||||
async move { generate_multi_agent_output(server_api, params, cancellation_rx).await },
|
||||
move |me, stream, ctx| {
|
||||
me.handle_response_stream_result(request_id, stream, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancels the stream. The conversation_id is preserved in the emitted event for async handling.
|
||||
pub(super) fn cancel(
|
||||
&mut self,
|
||||
reason: CancellationReason,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.current_request_id = None;
|
||||
let Some(cancellation_tx) = self.cancellation_tx.take() else {
|
||||
return;
|
||||
};
|
||||
let _ = cancellation_tx.send(());
|
||||
ctx.emit(ResponseStreamEvent::AfterStreamFinished {
|
||||
cancellation: Some(StreamCancellation {
|
||||
reason,
|
||||
conversation_id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_response_stream_result(
|
||||
&mut self,
|
||||
request_id: Uuid,
|
||||
stream_result: Result<api::ResponseStream, ConvertToAPITypeError>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
ctx.spawn_stream_local(
|
||||
stream,
|
||||
move |me, event, ctx| {
|
||||
me.handle_response_stream_event(request_id, event, ctx);
|
||||
},
|
||||
move |me, ctx| {
|
||||
me.on_response_stream_complete(request_id, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to send request to multi-agent API: {e:?}");
|
||||
self.on_response_stream_complete(request_id, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_response_stream_event(
|
||||
&mut self,
|
||||
request_id: Uuid,
|
||||
event: api::Event,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.current_request_id.is_none_or(|id| id != request_id) {
|
||||
return;
|
||||
}
|
||||
self.time_to_latest_event = Local::now().signed_duration_since(self.start_time);
|
||||
|
||||
match &event {
|
||||
Ok(response_event) => {
|
||||
if let Some(event_type) = &response_event.r#type {
|
||||
match event_type {
|
||||
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
||||
// Capture server_output_id from StreamInit event
|
||||
self.ai_identifiers.server_output_id =
|
||||
Some(crate::ai::agent::ServerOutputId::new(
|
||||
init_event.request_id.clone(),
|
||||
));
|
||||
}
|
||||
warp_multi_agent_api::response_event::Type::ClientActions(_) => {
|
||||
// Mark that we've received client actions
|
||||
self.has_received_client_actions = true;
|
||||
}
|
||||
warp_multi_agent_api::response_event::Type::Finished(finished_event) => {
|
||||
// Emit retry success telemetry on successful completion
|
||||
if matches!(
|
||||
finished_event.reason,
|
||||
Some(warp_multi_agent_api::response_event::stream_finished::Reason::Done(_)) | None
|
||||
) {
|
||||
// Emit retry success telemetry if this was a successful completion after retries
|
||||
if self.retry_count > 0 {
|
||||
if let Some(original_error) = &self.original_error {
|
||||
send_telemetry_from_ctx!(
|
||||
crate::TelemetryEvent::AgentModeRequestRetrySucceeded {
|
||||
identifiers: self.ai_identifiers.clone(),
|
||||
retry_count: self.retry_count,
|
||||
original_error: original_error.clone(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
|
||||
}
|
||||
Err(e) => {
|
||||
// Store original error if this is the first error
|
||||
if self.retry_count == 0 {
|
||||
self.original_error = Some(format!("{e:?}"));
|
||||
}
|
||||
|
||||
// Only retry if:
|
||||
// 1. We haven't received any client actions yet (this is the first event or only init events)
|
||||
// 2. The error is retryable
|
||||
// 3. We haven't exceeded max retries
|
||||
// 4. We're online
|
||||
const MAX_RETRIES: usize = 3;
|
||||
let network_status = NetworkStatus::as_ref(ctx);
|
||||
let is_online = network_status.is_online();
|
||||
let is_retryable = e.is_retryable();
|
||||
|
||||
let should_retry = !self.has_received_client_actions
|
||||
&& is_retryable
|
||||
&& self.retry_count < MAX_RETRIES
|
||||
&& is_online;
|
||||
|
||||
if should_retry {
|
||||
log::warn!(
|
||||
"MultiAgent request failed, retrying (attempt {}/{}) - Error: {e:?}",
|
||||
self.retry_count + 1,
|
||||
MAX_RETRIES
|
||||
);
|
||||
// Only emit error telemetry here if we're retrying.
|
||||
// Final errors that aren't being retried are emitted elsewhere.
|
||||
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
|
||||
self.retry(ctx);
|
||||
// Don't emit the error event, we're retrying
|
||||
// TODO: emit a separate event if controller needs to know about failures that are being retried
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't retry (because client actions were received) but the error is
|
||||
// retryable and we're allowed to attempt a resume, signal that the controller
|
||||
// should resume the conversation after the stream completes.
|
||||
let should_attempt_resume = self.has_received_client_actions
|
||||
&& is_retryable
|
||||
&& self.can_attempt_resume_on_error;
|
||||
if should_attempt_resume {
|
||||
self.should_resume_conversation_after_stream_finished = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "crash_reporting")]
|
||||
sentry::with_scope(
|
||||
|scope| {
|
||||
scope.set_tag(
|
||||
"has_received_client_actions",
|
||||
self.has_received_client_actions,
|
||||
);
|
||||
scope.set_tag("error", format!("{e:?}"));
|
||||
scope.set_tag("is_retryable", e.is_retryable());
|
||||
scope.set_tag("is_online", is_online);
|
||||
scope.set_tag("retry_count", self.retry_count);
|
||||
},
|
||||
|| {
|
||||
report_error!(anyhow!(e.clone()).context(format!(
|
||||
"MultiAgent request failed after {} retries",
|
||||
self.retry_count
|
||||
)));
|
||||
},
|
||||
);
|
||||
#[cfg(not(feature = "crash_reporting"))]
|
||||
{
|
||||
report_error!(anyhow!(e.clone()).context(format!(
|
||||
"MultiAgent request failed after {} retries",
|
||||
self.retry_count
|
||||
)));
|
||||
}
|
||||
|
||||
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_response_stream_complete(&mut self, request_id: Uuid, ctx: &mut ModelContext<Self>) {
|
||||
if self.current_request_id.is_none_or(|id| id != request_id) {
|
||||
return;
|
||||
}
|
||||
ctx.emit(ResponseStreamEvent::AfterStreamFinished { cancellation: None });
|
||||
self.cancellation_tx = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Consumable<T> {
|
||||
value: Rc<RefCell<Option<T>>>,
|
||||
}
|
||||
|
||||
impl<T> Consumable<T> {
|
||||
fn new(value: T) -> Self {
|
||||
Consumable {
|
||||
value: Rc::new(RefCell::new(Some(value))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn consume(&self) -> Option<T> {
|
||||
self.value.borrow_mut().take()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Consumable<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Consumable {
|
||||
value: Rc::clone(&self.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancellation context preserved for async event handling.
|
||||
/// Includes conversation_id because truncation can remove exchange mappings before the event is processed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamCancellation {
|
||||
pub reason: CancellationReason,
|
||||
pub conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResponseStreamEvent {
|
||||
ReceivedEvent(Consumable<api::Event>),
|
||||
AfterStreamFinished {
|
||||
/// Some for cancellation (with context), None for natural completion (uses dynamic lookup).
|
||||
cancellation: Option<StreamCancellation>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for ResponseStream {
|
||||
type Event = ResponseStreamEvent;
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
// Session-sharing specific logic for BlocklistAIController.
|
||||
// This module extends BlocklistAIController with methods used when viewing a shared session
|
||||
// and defines state used only for session sharing.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_multi_agent_api::response_event::{stream_finished, ClientActions};
|
||||
use warp_multi_agent_api::{client_action::Action, message::Message};
|
||||
|
||||
use super::response_stream::ResponseStreamId;
|
||||
use super::{BlocklistAIController, RequestInput};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentAttachment, EntrypointType};
|
||||
use crate::ai::attachment_utils::{
|
||||
build_file_attachment_map, download_file, sanitize_filename, DownloadedAttachment,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SharedSessionState {
|
||||
// The current active request id for the shared session (used if subsequent events do not provide a request id)
|
||||
current_response_id: Option<ResponseStreamId>,
|
||||
// The participant who initiated the current response stream
|
||||
current_response_initiator: Option<ParticipantId>,
|
||||
// The sharer's participant ID (set when session sharing starts)
|
||||
sharer_participant_id: Option<ParticipantId>,
|
||||
}
|
||||
|
||||
impl BlocklistAIController {
|
||||
/// Returns the current conversation ID for the active shared session stream.
|
||||
/// Returns None if there's no active shared session conversation.
|
||||
pub(crate) fn get_current_shared_session_conversation_id(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
self.shared_session_state
|
||||
.current_response_id
|
||||
.as_ref()
|
||||
.and_then(|response_id| {
|
||||
BlocklistAIHistoryModel::as_ref(app).conversation_for_response_stream(response_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle a shared cancel control action and cancel the provided conversation
|
||||
/// (if it exists and is live).
|
||||
pub fn handle_shared_session_cancel_action(
|
||||
&mut self,
|
||||
server_conversation_token: ServerConversationToken,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(conversation_id) = self.find_existing_conversation_by_server_token(
|
||||
&server_conversation_token.to_string(),
|
||||
ctx,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if BlocklistAIHistoryModel::as_ref(ctx).is_conversation_live(conversation_id) {
|
||||
self.cancel_conversation_progress(
|
||||
conversation_id,
|
||||
super::CancellationReason::ManuallyCancelled,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply agent session events to the current conversation state.
|
||||
pub fn handle_shared_session_response_event(
|
||||
&mut self,
|
||||
resp: warp_multi_agent_api::ResponseEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(kind) = resp.r#type else {
|
||||
return;
|
||||
};
|
||||
match kind {
|
||||
warp_multi_agent_api::response_event::Type::Init(init) => {
|
||||
self.on_shared_init(init, ctx)
|
||||
}
|
||||
warp_multi_agent_api::response_event::Type::ClientActions(actions) => {
|
||||
self.on_shared_client_actions(actions, ctx)
|
||||
}
|
||||
warp_multi_agent_api::response_event::Type::Finished(finished) => {
|
||||
self.on_shared_finished(finished, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_shared_init(
|
||||
&mut self,
|
||||
init_event: warp_multi_agent_api::response_event::StreamInit,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let stream_id = ResponseStreamId::for_shared_session(&init_event);
|
||||
self.shared_session_state.current_response_id = Some(stream_id.clone());
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
|
||||
// If the server conversation already exists locally (matched by server_conversation_token), reuse it.
|
||||
// Otherwise, if we're currently in an empty agent view conversation, reuse that
|
||||
// local conversation ID and bind the incoming server token to it.
|
||||
// This preserves block visibility for terminal blocks created in the given agent view.
|
||||
let conversation_id = self
|
||||
.find_existing_conversation_by_server_token(&init_event.conversation_id, ctx)
|
||||
.or_else(|| {
|
||||
let selected_conversation_id = self
|
||||
.context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)?;
|
||||
|
||||
// If the current agent view's conversation is completely empty,
|
||||
// we should just associate it with the incoming request/token.
|
||||
let should_reuse_selected_conversation = history
|
||||
.as_ref(ctx)
|
||||
.conversation(&selected_conversation_id)
|
||||
.is_some_and(|conversation| {
|
||||
conversation.exchange_count() == 0
|
||||
&& conversation.server_conversation_token().is_none()
|
||||
});
|
||||
if !should_reuse_selected_conversation {
|
||||
return None;
|
||||
}
|
||||
|
||||
history.update(ctx, |history, ctx| {
|
||||
history.set_server_conversation_token_for_conversation(
|
||||
selected_conversation_id,
|
||||
init_event.conversation_id.clone(),
|
||||
);
|
||||
history.set_viewing_shared_session_for_conversation(
|
||||
selected_conversation_id,
|
||||
true,
|
||||
);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Some(selected_conversation_id)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
history.update(ctx, |h, ctx| {
|
||||
h.start_new_conversation(terminal_view_id, false, true, ctx)
|
||||
})
|
||||
});
|
||||
|
||||
let Some(conversation) = history.as_ref(ctx).conversation(&conversation_id) else {
|
||||
log::error!(
|
||||
"Tried to initialize shared session stream for non-existent conversation {conversation_id:?}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let task_id = conversation.get_root_task_id().clone();
|
||||
|
||||
// Ensure the action executor is in view-only mode for shared-session viewers.
|
||||
self.action_model.update(ctx, |action_model, _ctx| {
|
||||
action_model.set_view_only(true);
|
||||
});
|
||||
|
||||
// Eagerly create an exchange for this request (with empty inputs) and initialize output.
|
||||
history.update(ctx, |history_model, ctx| {
|
||||
let _ = history_model.update_conversation_for_new_request_input(
|
||||
RequestInput::for_task(
|
||||
vec![],
|
||||
task_id,
|
||||
&self.active_session,
|
||||
self.get_current_response_initiator(),
|
||||
conversation_id,
|
||||
self.terminal_view_id,
|
||||
ctx,
|
||||
),
|
||||
stream_id.clone(),
|
||||
self.terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
|
||||
history_model.initialize_output_for_response_stream(
|
||||
&stream_id,
|
||||
conversation_id,
|
||||
self.terminal_view_id,
|
||||
init_event.clone(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Mark conversation as in progress and active/selected
|
||||
history_model.update_conversation_status(
|
||||
self.terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::InProgress,
|
||||
ctx,
|
||||
);
|
||||
history_model.set_active_conversation_id(conversation_id, self.terminal_view_id, ctx);
|
||||
});
|
||||
self.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.set_pending_query_state_for_existing_conversation(
|
||||
conversation_id,
|
||||
AgentViewEntryOrigin::SharedSessionSelection,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_shared_client_actions(
|
||||
&mut self,
|
||||
actions: warp_multi_agent_api::response_event::ClientActions,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(stream_id) = self.shared_session_state.current_response_id.clone() else {
|
||||
log::warn!("Received shared session client actions with no active response stream id.");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(conversation_id) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(&stream_id)
|
||||
else {
|
||||
log::warn!(
|
||||
"No conversation ID for shared session response stream with id={stream_id:?}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
self.update_directory_context_from_client_actions(&actions, ctx);
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
history_model.update(ctx, |history_model, ctx| {
|
||||
if let Err(e) = history_model.apply_client_actions(
|
||||
&stream_id,
|
||||
actions.actions,
|
||||
conversation_id,
|
||||
self.terminal_view_id,
|
||||
ctx,
|
||||
) {
|
||||
log::error!(
|
||||
"Failed to apply client actions to conversation for shared session: {e:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
|
||||
log::error!("Failed to find conversation with id: {conversation_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
let new_action_results_to_apply = conversation
|
||||
.new_exchange_ids_for_response(&stream_id)
|
||||
.filter_map(|exchange_id| conversation.exchange_with_id(exchange_id))
|
||||
.flat_map(|exchange| {
|
||||
exchange
|
||||
.input
|
||||
.iter()
|
||||
.filter_map(|i| i.action_result().cloned())
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
// Apply finished results to unfinished actions.
|
||||
for result in new_action_results_to_apply.into_iter() {
|
||||
if self
|
||||
.action_model
|
||||
.as_ref(ctx)
|
||||
.get_action_result(&result.id)
|
||||
.is_none()
|
||||
{
|
||||
self.action_model.update(ctx, |action_model, ctx| {
|
||||
action_model.apply_finished_action_result(conversation_id, result, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the context model's working directory context using the most recent message context.
|
||||
fn update_directory_context_from_client_actions(
|
||||
&mut self,
|
||||
actions: &ClientActions,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
for client_action in &actions.actions {
|
||||
if let Some(Action::AddMessagesToTask(add)) = &client_action.action {
|
||||
for message in &add.messages {
|
||||
if let Some(inner) = &message.message {
|
||||
let ctx_opt = match inner {
|
||||
Message::UserQuery(uq) => uq.context.as_ref(),
|
||||
Message::SystemQuery(sq) => sq.context.as_ref(),
|
||||
Message::ToolCallResult(tcr) => tcr.context.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(input_ctx) = ctx_opt {
|
||||
if let Some(dir) = &input_ctx.directory {
|
||||
self.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.update_directory_context(
|
||||
if dir.pwd.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dir.pwd.clone())
|
||||
},
|
||||
if dir.home.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dir.home.clone())
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_shared_finished(
|
||||
&mut self,
|
||||
finished: warp_multi_agent_api::response_event::StreamFinished,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(stream_id) = self.shared_session_state.current_response_id.take() else {
|
||||
log::warn!("Shared Finished missing request_id");
|
||||
return;
|
||||
};
|
||||
let Some(conversation_id) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(&stream_id)
|
||||
else {
|
||||
log::warn!(
|
||||
"No conversation ID for shared session response stream with id={stream_id:?}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
|
||||
log::error!("Failed to find conversation with id: {conversation_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
// Queue actions for viewer UI in view-only mode
|
||||
let mut actions_to_queue = vec![];
|
||||
let mut did_exchange_contain_user_query = false;
|
||||
|
||||
for new_exchange_id in conversation.new_exchange_ids_for_response(&stream_id) {
|
||||
let Some(exchange) = conversation.exchange_with_id(new_exchange_id) else {
|
||||
continue;
|
||||
};
|
||||
did_exchange_contain_user_query |=
|
||||
exchange.input.iter().any(|input| input.is_user_query());
|
||||
|
||||
if let Some(output) = exchange.output_status.output() {
|
||||
actions_to_queue.extend(output.get().actions().cloned().collect_vec().into_iter());
|
||||
}
|
||||
}
|
||||
|
||||
if !actions_to_queue.is_empty() {
|
||||
self.action_model.update(ctx, |action_model, ctx| {
|
||||
action_model.queue_actions(actions_to_queue, conversation_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.handle_response_stream_finished(
|
||||
&stream_id,
|
||||
finished,
|
||||
conversation_id,
|
||||
did_exchange_contain_user_query,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds an existing client conversation whose server_conversation_token matches `server_token`.
|
||||
/// Searches only live conversations for this terminal view. Returns None if no match is found.
|
||||
pub fn find_existing_conversation_by_server_token(
|
||||
&self,
|
||||
server_token: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Option<AIConversationId> {
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
history
|
||||
.as_ref(ctx)
|
||||
.all_live_conversations_for_terminal_view(self.terminal_view_id)
|
||||
.find_map(|conv| {
|
||||
conv.server_conversation_token()
|
||||
.and_then(|t| (t.as_str() == server_token).then_some(conv.id()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a synthetic cancellation event to viewers when the sharer cancels a conversation.
|
||||
/// This ensures viewers see the conversation as cancelled and update their UI accordingly.
|
||||
pub(super) fn send_cancellation_to_viewers(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !self
|
||||
.terminal_model
|
||||
.lock()
|
||||
.shared_session_status()
|
||||
.is_sharer()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current conversation and build usage metadata from it.
|
||||
let conversation_id = self.get_current_shared_session_conversation_id(ctx);
|
||||
let usage_metadata = conversation_id.and_then(|conv_id| {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conv_id)
|
||||
.map(|conversation| stream_finished::ConversationUsageMetadata {
|
||||
context_window_usage: conversation.context_window_usage(),
|
||||
credits_spent: conversation.credits_spent(),
|
||||
summarized: conversation.was_summarized(),
|
||||
#[allow(deprecated)]
|
||||
token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.map(|u| u.to_proto_combined())
|
||||
.collect(),
|
||||
tool_usage_metadata: Some(conversation.tool_usage_metadata().into()),
|
||||
warp_token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_warp_usage())
|
||||
.collect(),
|
||||
byok_token_usage: conversation
|
||||
.token_usage()
|
||||
.iter()
|
||||
.filter_map(|u| u.to_proto_byok_usage())
|
||||
.collect(),
|
||||
})
|
||||
});
|
||||
|
||||
// Create a synthetic StreamFinished event to notify viewers of the cancellation.
|
||||
// We use "Done" reason rather than a specific cancellation reason because
|
||||
// the proto doesn't have explicit variants for UserCommandExecuted or ManuallyCancelled.
|
||||
// TODO: we should probably add representations for said variants in the proto for this usecase.
|
||||
let finished_event = warp_multi_agent_api::ResponseEvent {
|
||||
r#type: Some(warp_multi_agent_api::response_event::Type::Finished(
|
||||
warp_multi_agent_api::response_event::StreamFinished {
|
||||
reason: Some(stream_finished::Reason::Done(stream_finished::Done {})),
|
||||
conversation_usage_metadata: usage_metadata,
|
||||
token_usage: vec![],
|
||||
should_refresh_model_config: false,
|
||||
request_cost: None,
|
||||
},
|
||||
)),
|
||||
};
|
||||
|
||||
// Send the cancellation event to viewers.
|
||||
// If no initiator is tracked, fall back to the sharer's participant ID.
|
||||
let forked_from_token = conversation_id.and_then(|conv_id| {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conv_id)
|
||||
.and_then(|conv| {
|
||||
conv.forked_from_server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
});
|
||||
self.terminal_model
|
||||
.lock()
|
||||
.send_agent_response_for_shared_session(
|
||||
&finished_event,
|
||||
self.get_current_response_initiator()
|
||||
.or_else(|| self.get_sharer_participant_id()),
|
||||
forked_from_token,
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks an action as remotely executing when a viewer receives a CommandExecutionStarted event.
|
||||
/// This allows the viewer's UI to show the action as running rather than queued.
|
||||
pub fn mark_action_as_remotely_executing_in_shared_session(
|
||||
&mut self,
|
||||
action_id: &AIAgentActionId,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.action_model.update(ctx, |action_model, ctx| {
|
||||
action_model.mark_action_as_remotely_executing(action_id, conversation_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the participant ID for the current response.
|
||||
/// This should be called when initiating a query to track who sent it.
|
||||
pub fn set_current_response_initiator(&mut self, participant_id: ParticipantId) {
|
||||
self.shared_session_state.current_response_initiator = Some(participant_id);
|
||||
}
|
||||
|
||||
/// Gets the participant ID for the current response.
|
||||
pub(super) fn get_current_response_initiator(&self) -> Option<ParticipantId> {
|
||||
self.shared_session_state.current_response_initiator.clone()
|
||||
}
|
||||
|
||||
/// Sets the sharer's participant ID. Should be called when a shared session is created.
|
||||
pub fn set_sharer_participant_id(&mut self, participant_id: ParticipantId) {
|
||||
self.shared_session_state.sharer_participant_id = Some(participant_id);
|
||||
}
|
||||
|
||||
/// Gets the sharer's participant ID.
|
||||
pub(super) fn get_sharer_participant_id(&self) -> Option<ParticipantId> {
|
||||
self.shared_session_state.sharer_participant_id.clone()
|
||||
}
|
||||
|
||||
/// Links a forked conversation's new token to an existing conversation.
|
||||
/// This is called on the viewer side when receiving a response for a forked conversation
|
||||
/// so that new responses are added to the correct conversation.
|
||||
pub fn link_forked_conversation_token(
|
||||
&mut self,
|
||||
forked_from_token: &str,
|
||||
event: &warp_multi_agent_api::ResponseEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Extract the new server conversation id from the StreamInit event
|
||||
let new_conversation_id = match &event.r#type {
|
||||
Some(warp_multi_agent_api::response_event::Type::Init(init)) => {
|
||||
init.conversation_id.as_str()
|
||||
}
|
||||
// Only StreamInit events have conversation_id.
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Find the conversation with the forked_from token
|
||||
if let Some(conversation_id) =
|
||||
self.find_existing_conversation_by_server_token(forked_from_token, ctx)
|
||||
{
|
||||
// Update the conversation's server_conversation_token to the new one
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.set_server_conversation_token_for_conversation(
|
||||
conversation_id,
|
||||
new_conversation_id.to_string(),
|
||||
);
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an agent prompt on behalf of the viewer.
|
||||
pub fn execute_agent_prompt_for_shared_session(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
server_conversation_token: Option<ServerConversationToken>,
|
||||
attachments: Vec<AgentAttachment>,
|
||||
participant_id: ParticipantId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Map server token to sharer's local conversation ID
|
||||
let conversation_id = server_conversation_token
|
||||
.and_then(|id| self.find_existing_conversation_by_server_token(&id.to_string(), ctx))
|
||||
.and_then(
|
||||
|id| match BlocklistAIHistoryModel::as_ref(ctx).conversation(&id) {
|
||||
Some(c) => Some(c),
|
||||
None => {
|
||||
log::error!(
|
||||
"Tried to execute prompt for non-existent conversation: {id:?}",
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.map(|conversation| conversation.id());
|
||||
|
||||
// Process attachments and set them in the context model
|
||||
let mut block_ids = Vec::new();
|
||||
let mut selected_text_parts = Vec::new();
|
||||
let mut file_downloads: Vec<(String, String)> = Vec::new();
|
||||
for attachment in attachments {
|
||||
match attachment {
|
||||
AgentAttachment::BlockReference { block_id } => {
|
||||
// Convert protocol BlockId to app BlockId
|
||||
block_ids.push(BlockId::from(block_id.to_string()));
|
||||
}
|
||||
AgentAttachment::PlainText { content } => {
|
||||
selected_text_parts.push(content);
|
||||
}
|
||||
AgentAttachment::FileReference {
|
||||
attachment_id,
|
||||
file_name,
|
||||
} => {
|
||||
file_downloads.push((attachment_id, file_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set block and text attachments in the context model.
|
||||
self.context_model.update(ctx, |context_model, ctx| {
|
||||
// Set block IDs if any were provided
|
||||
if !block_ids.is_empty() {
|
||||
context_model.set_pending_context_block_ids(block_ids, false, ctx);
|
||||
}
|
||||
|
||||
// Set selected text if any was provided
|
||||
if !selected_text_parts.is_empty() {
|
||||
let combined_text = selected_text_parts.join("\n");
|
||||
context_model.set_pending_context_selected_text(Some(combined_text), false, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// If there are no file downloads (or the feature is disabled), send the query immediately.
|
||||
if file_downloads.is_empty() || !FeatureFlag::CloudModeImageContext.is_enabled() {
|
||||
self.send_shared_session_query(
|
||||
prompt,
|
||||
conversation_id,
|
||||
participant_id,
|
||||
HashMap::new(),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We have file downloads — ensure both the download dir and task ID are available.
|
||||
let Some(attachment_dir) = self.attachments_download_dir.clone() else {
|
||||
log::error!(
|
||||
"No attachments_download_dir set on controller, cannot process file attachments"
|
||||
);
|
||||
self.send_shared_session_query(
|
||||
prompt,
|
||||
conversation_id,
|
||||
participant_id,
|
||||
HashMap::new(),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(task_id) = self.ambient_agent_task_id else {
|
||||
log::error!("No task_id available to download attachments");
|
||||
self.send_shared_session_query(
|
||||
prompt,
|
||||
conversation_id,
|
||||
participant_id,
|
||||
HashMap::new(),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let attachment_ids: Vec<String> = file_downloads.iter().map(|(id, _)| id.clone()).collect();
|
||||
|
||||
// Fetch presigned download URLs from the server, download files to disk,
|
||||
// then build the attachment map from only the successfully downloaded files.
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let download_urls = match ai_client
|
||||
.download_task_attachments(&task_id, &attachment_ids)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(|att| (att.attachment_id, att.download_url))
|
||||
.collect::<std::collections::HashMap<_, _>>(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to get download URLs for task {task_id}: {e}");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = async_fs::create_dir_all(&attachment_dir).await {
|
||||
log::error!("Failed to create attachments directory: {e}");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut downloaded = Vec::new();
|
||||
for (attachment_id, file_name) in &file_downloads {
|
||||
let Some(url) = download_urls.get(attachment_id) else {
|
||||
log::warn!("No download URL for attachment {attachment_id}");
|
||||
continue;
|
||||
};
|
||||
let safe_name = sanitize_filename(file_name).to_string();
|
||||
let dest = attachment_dir.join(format!("{attachment_id}_{safe_name}"));
|
||||
|
||||
match download_file(server_api.http_client(), url, &dest).await {
|
||||
Ok(_) => {
|
||||
downloaded.push(DownloadedAttachment {
|
||||
file_id: attachment_id.clone(),
|
||||
file_name: safe_name,
|
||||
file_path: dest.to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to download {safe_name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
downloaded
|
||||
},
|
||||
move |controller, downloaded, ctx| {
|
||||
let file_attachments = build_file_attachment_map(&downloaded);
|
||||
controller.send_shared_session_query(
|
||||
prompt,
|
||||
conversation_id,
|
||||
participant_id,
|
||||
file_attachments,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to send a shared-session query, used both for immediate sends
|
||||
/// (no file attachments) and deferred sends (after file downloads complete).
|
||||
fn send_shared_session_query(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
participant_id: ParticipantId,
|
||||
file_attachments: HashMap<String, AIAgentAttachment>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
// Enter agent view for this conversation so the sharer's UI state is correct
|
||||
// and updates are sent to the viewer.
|
||||
self.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.set_pending_query_state_for_existing_conversation(
|
||||
conversation_id,
|
||||
AgentViewEntryOrigin::SharedSessionSelection,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
self.send_user_query_in_conversation_with_attachments(
|
||||
prompt,
|
||||
conversation_id,
|
||||
Some(participant_id),
|
||||
file_attachments,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
// If we're already in an empty agent view conversation, reuse it
|
||||
// (so that any command blocks remain visible). Otherwise create a new one for the given prompt.
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let origin = AgentViewEntryOrigin::SharedSessionSelection;
|
||||
|
||||
let Some(conversation_id) = self
|
||||
.context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
.filter(|conversation_id| {
|
||||
history
|
||||
.as_ref(ctx)
|
||||
.conversation(conversation_id)
|
||||
.is_some_and(|conversation| {
|
||||
conversation.exchange_count() == 0
|
||||
&& conversation.server_conversation_token().is_none()
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model
|
||||
.try_enter_agent_view_for_new_conversation(origin, ctx)
|
||||
.ok()
|
||||
})
|
||||
})
|
||||
else {
|
||||
log::error!("Failed to get conversation id for shared session prompt");
|
||||
return;
|
||||
};
|
||||
|
||||
self.send_user_query_in_conversation_with_attachments(
|
||||
prompt,
|
||||
conversation_id,
|
||||
Some(participant_id),
|
||||
file_attachments,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.send_user_query_in_new_conversation(
|
||||
prompt,
|
||||
None,
|
||||
EntrypointType::SharedSession,
|
||||
Some(participant_id),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentContext, AIAgentInput, CloneRepositoryURL,
|
||||
EntrypointType, RequestMetadata,
|
||||
},
|
||||
blocklist::agent_view::AgentViewEntryOrigin,
|
||||
},
|
||||
search::slash_command_menu::static_commands::commands,
|
||||
terminal::input::slash_commands::SlashCommandTrigger,
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
|
||||
use super::{
|
||||
input_context_for_request, parse_context_attachments, BlocklistAIController,
|
||||
BlocklistAIControllerEvent, RequestInput,
|
||||
};
|
||||
|
||||
pub enum SlashCommandRequest {
|
||||
CreateNewProject {
|
||||
query: String,
|
||||
},
|
||||
CloneRepository {
|
||||
url: String,
|
||||
},
|
||||
InitProjectRules,
|
||||
CreateEnvironment {
|
||||
repos: Vec<String>,
|
||||
use_current_dir: bool,
|
||||
},
|
||||
Summarize {
|
||||
prompt: Option<String>,
|
||||
},
|
||||
FetchReviewComments {
|
||||
repo_path: String,
|
||||
},
|
||||
/// Invoke a skill.
|
||||
InvokeSkill {
|
||||
skill: ai::skills::ParsedSkill,
|
||||
user_query: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SlashCommandRequest {
|
||||
/// Parses user input into a SlashCommandRequest for slash commands that are handled
|
||||
/// via the AI query flow (as opposed to action-based slash commands handled in input.rs).
|
||||
pub fn from_query(query: &str) -> Option<SlashCommandRequest> {
|
||||
// Check if this is an exact /init query and route it to InitProjectRules instead
|
||||
if query == "/init" {
|
||||
return Some(Self::InitProjectRules);
|
||||
}
|
||||
|
||||
// Check if query starts with /compact and route to summarize conversation
|
||||
if let Some(prompt) = query.strip_prefix(commands::COMPACT.name) {
|
||||
return Some(Self::Summarize {
|
||||
prompt: prompt.strip_prefix(' ').map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn send_request(
|
||||
self,
|
||||
controller: &mut BlocklistAIController,
|
||||
is_queued_prompt: bool,
|
||||
ctx: &mut ModelContext<BlocklistAIController>,
|
||||
) {
|
||||
let conversation_id = self.conversation_id(controller, ctx);
|
||||
// For skill invocations, include user-attached context (images, blocks, and selected
|
||||
// text) so the skill's agent sees the same attachments a non-slash-command user query
|
||||
// would. Other slash commands continue to pass `false` to preserve existing behavior.
|
||||
let is_invoke_skill = matches!(self, Self::InvokeSkill { .. });
|
||||
let context = input_context_for_request(
|
||||
is_invoke_skill,
|
||||
controller.context_model.as_ref(ctx),
|
||||
controller.active_session.as_ref(ctx),
|
||||
conversation_id,
|
||||
vec![],
|
||||
ctx,
|
||||
);
|
||||
let entrypoint = self.entrypoint();
|
||||
let is_summarize = matches!(self, Self::Summarize { .. });
|
||||
let inputs = self.input(context, controller.context_model.as_ref(ctx), ctx);
|
||||
if inputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no existing conversation, create a new one.
|
||||
// When AgentView is enabled, enter agent view which creates the conversation
|
||||
// and ensures AI blocks render correctly in the agent view.
|
||||
let Some(conversation_id) = conversation_id.or_else(|| {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
controller.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model
|
||||
.try_enter_agent_view_for_new_conversation(
|
||||
AgentViewEntryOrigin::SlashCommand {
|
||||
trigger: SlashCommandTrigger::input(),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
} else {
|
||||
Some(controller.start_new_conversation_for_request(ctx).id())
|
||||
}
|
||||
}) else {
|
||||
log::error!("Failed to get conversation ID for slash command request");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let request_input = RequestInput::for_task(
|
||||
inputs,
|
||||
conversation.get_root_task_id().clone(),
|
||||
&controller.active_session,
|
||||
controller.get_current_response_initiator(),
|
||||
conversation_id,
|
||||
controller.terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
let model_id = request_input.model_id.clone();
|
||||
|
||||
match controller.send_request_input(
|
||||
request_input,
|
||||
Some(RequestMetadata {
|
||||
is_autodetected_user_query: false,
|
||||
entrypoint,
|
||||
is_auto_resume_after_error: false,
|
||||
}),
|
||||
/*default_to_follow_up_on_success*/ true,
|
||||
/*can_attempt_resume_on_error*/ true,
|
||||
is_queued_prompt,
|
||||
ctx,
|
||||
) {
|
||||
Ok((_, stream_id)) => {
|
||||
// Skill invocations now consume user-attached context (images, blocks, and
|
||||
// selected text) the same way regular user queries do. `send_request_input`
|
||||
// only clears that context for `AIAgentInput::UserQuery`, so we mirror its
|
||||
// reset here for `InvokeSkill` to avoid pending attachments sticking around
|
||||
// and getting re-sent on subsequent messages.
|
||||
if is_invoke_skill {
|
||||
controller.context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.reset_context_to_default(ctx);
|
||||
});
|
||||
}
|
||||
// Emit SentRequest event to trigger buffer clearing
|
||||
if is_summarize {
|
||||
ctx.emit(BlocklistAIControllerEvent::SentRequest {
|
||||
contains_user_query: true,
|
||||
is_queued_prompt,
|
||||
model_id,
|
||||
stream_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Failed to send agent slash command request: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_id(
|
||||
&self,
|
||||
controller: &BlocklistAIController,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
match self {
|
||||
Self::Summarize { .. }
|
||||
| Self::CreateEnvironment { .. }
|
||||
| Self::InvokeSkill { .. }
|
||||
| Self::FetchReviewComments { .. } => controller
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.selected_conversation_id(app),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn input(
|
||||
self,
|
||||
context: Arc<[AIAgentContext]>,
|
||||
context_model: &crate::ai::blocklist::BlocklistAIContextModel,
|
||||
app: &AppContext,
|
||||
) -> Vec<AIAgentInput> {
|
||||
match self {
|
||||
SlashCommandRequest::CreateNewProject { query } => {
|
||||
vec![AIAgentInput::CreateNewProject { query, context }]
|
||||
}
|
||||
SlashCommandRequest::CloneRepository { url } => {
|
||||
vec![AIAgentInput::CloneRepository {
|
||||
clone_repo_url: CloneRepositoryURL::new(url),
|
||||
context,
|
||||
}]
|
||||
}
|
||||
SlashCommandRequest::InitProjectRules => vec![AIAgentInput::InitProjectRules {
|
||||
context,
|
||||
display_query: Some("/init".to_string()),
|
||||
}],
|
||||
SlashCommandRequest::CreateEnvironment {
|
||||
mut repos,
|
||||
use_current_dir,
|
||||
} => {
|
||||
let display_query = if repos.is_empty() {
|
||||
"/create-environment".to_string()
|
||||
} else {
|
||||
format!("/create-environment {}", repos.join(" "))
|
||||
};
|
||||
|
||||
// Add "." to represent the current working directory
|
||||
if use_current_dir {
|
||||
repos.push(String::from("."));
|
||||
}
|
||||
|
||||
vec![AIAgentInput::CreateEnvironment {
|
||||
context,
|
||||
display_query: Some(display_query),
|
||||
repo_paths: repos,
|
||||
}]
|
||||
}
|
||||
SlashCommandRequest::Summarize { prompt, .. } => {
|
||||
vec![AIAgentInput::SummarizeConversation { prompt }]
|
||||
}
|
||||
SlashCommandRequest::FetchReviewComments { repo_path } => {
|
||||
vec![AIAgentInput::FetchReviewComments { repo_path, context }]
|
||||
}
|
||||
SlashCommandRequest::InvokeSkill { skill, user_query } => {
|
||||
let user_query = if FeatureFlag::SkillArguments.is_enabled() {
|
||||
user_query
|
||||
.map(|query| query.trim().to_string())
|
||||
.filter(|query| !query.is_empty())
|
||||
.map(|query| crate::ai::agent::InvokeSkillUserQuery {
|
||||
referenced_attachments: parse_context_attachments(
|
||||
&query,
|
||||
context_model,
|
||||
app,
|
||||
),
|
||||
query,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
vec![AIAgentInput::InvokeSkill {
|
||||
skill,
|
||||
user_query,
|
||||
context,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entrypoint(&self) -> EntrypointType {
|
||||
match self {
|
||||
SlashCommandRequest::CloneRepository { .. } => EntrypointType::CloneRepository,
|
||||
SlashCommandRequest::InitProjectRules => EntrypointType::InitProjectRules,
|
||||
SlashCommandRequest::CreateNewProject { .. }
|
||||
| SlashCommandRequest::CreateEnvironment { .. }
|
||||
| SlashCommandRequest::Summarize { .. }
|
||||
| SlashCommandRequest::FetchReviewComments { .. }
|
||||
| SlashCommandRequest::InvokeSkill { .. } => EntrypointType::UserInitiated,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user