Reduce indexing overhead and repair agent search and child recovery

This commit is contained in:
2026-09-14 16:25:49 -05:00
parent 81b4c78b61
commit 506dc5b225
38 changed files with 1941 additions and 218 deletions
@@ -27,12 +27,21 @@ use crate::features::FeatureFlag;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
#[path = "search_codebase_context.rs"]
mod context;
use context::{bound_search_result, MAX_SEARCH_FILES, MAX_SEARCH_SCAN_BYTES};
struct PendingSearch {
sender: oneshot::Sender<SearchCodebaseResult>,
query: String,
}
pub struct SearchCodebaseExecutor {
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
/// Per-action response channels for searches that are still waiting on
/// `GetRelevantFilesController`.
active_searches: HashMap<AIAgentActionId, oneshot::Sender<SearchCodebaseResult>>,
active_searches: HashMap<AIAgentActionId, PendingSearch>,
/// Cached repo roots derived during preprocessing so permission checks and execution can agree
/// on which repository the action actually targets.
root_repo_paths: HashMap<AIAgentActionId, PathBuf>,
@@ -59,9 +68,7 @@ impl SearchCodebaseExecutor {
let Some(result_tx) = me.active_searches.remove(action_id) else {
return;
};
if let Err(e) = result_tx.send(result.clone()) {
log::warn!("Failed to send search codebase results to receiver {e:?}.");
}
Self::send_bounded_result(result_tx, result.clone(), ctx);
}
GetRelevantFilesControllerEvent::Success {
result: GetRelevantFilesControllerResult::Locations(fragments),
@@ -70,6 +77,8 @@ impl SearchCodebaseExecutor {
let action_id = event.action_id().clone();
let locations = fragments
.iter()
.sorted_by_key(|location| location.path())
.take(MAX_SEARCH_FILES)
.map(|location| location.into())
.collect_vec();
let current_working_directory = me
@@ -84,13 +93,15 @@ impl SearchCodebaseExecutor {
&locations,
current_working_directory,
shell,
None,
None,
Some(MAX_SEARCH_SCAN_BYTES),
Some(MAX_SEARCH_SCAN_BYTES * MAX_SEARCH_FILES),
)
.await
{
Ok(result) => {
if !result.missing_files.is_empty() {
if result.file_contexts.is_empty()
&& !result.missing_files.is_empty()
{
let missing_files = result.missing_files.join(", ");
SearchCodebaseResult::Failed {
message: format!(
@@ -110,15 +121,11 @@ impl SearchCodebaseExecutor {
},
}
},
move |me, result, _| {
move |me, result, ctx| {
let Some(result_tx) = me.active_searches.remove(&action_id) else {
return;
};
if let Err(e) = result_tx.send(result) {
log::warn!(
"Failed to send search codebase results to receiver {e:?}."
);
}
Self::send_bounded_result(result_tx, result, ctx);
},
);
}
@@ -126,7 +133,7 @@ impl SearchCodebaseExecutor {
let Some(result_tx) = me.active_searches.remove(action_id) else {
return;
};
if let Err(e) = result_tx.send(SearchCodebaseResult::Failed {
if let Err(e) = result_tx.sender.send(SearchCodebaseResult::Failed {
message: "The search failed. Try another way to locate the relevant files."
.to_owned(),
reason: SearchCodebaseFailureReason::GetRelevantFilesError,
@@ -146,6 +153,21 @@ impl SearchCodebaseExecutor {
}
}
fn send_bounded_result(
pending: PendingSearch,
result: SearchCodebaseResult,
ctx: &mut ModelContext<Self>,
) {
ctx.spawn(
async move { (pending.sender, bound_search_result(result, &pending.query)) },
|_, (sender, result), _| {
if let Err(error) = sender.send(result) {
log::debug!("Search result receiver was dropped: {error:?}");
}
},
);
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
@@ -245,7 +267,13 @@ impl SearchCodebaseExecutor {
});
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
self.active_searches.insert(
id.clone(),
PendingSearch {
sender: result_tx,
query: query.clone(),
},
);
match self
.get_relevant_files_controller
@@ -358,7 +386,13 @@ impl SearchCodebaseExecutor {
});
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
self.active_searches.insert(
id.clone(),
PendingSearch {
sender: result_tx,
query: query.clone(),
},
);
// Start the actual search.
match self
@@ -0,0 +1,121 @@
//! Bounded discovery excerpts. Explicit `read_files` calls provide full follow-up context.
use std::collections::HashSet;
pub(super) use ai::agent::action_result::MAX_SEARCH_CODEBASE_FILES as MAX_SEARCH_FILES;
use crate::ai::agent::{AnyFileContent, FileContext, SearchCodebaseResult};
pub(super) const MAX_SEARCH_SCAN_BYTES: usize = 256_000;
const MAX_EXCERPT_BYTES: usize = 4_000;
const MAX_EXCERPT_LINES: usize = 40;
pub(super) fn bound_search_result(
result: SearchCodebaseResult,
query: &str,
) -> SearchCodebaseResult {
let SearchCodebaseResult::Success { files } = result else {
return result;
};
let terms: Vec<_> = query
.split(|character: char| !character.is_alphanumeric() && character != '_')
.filter(|term| term.len() >= 3)
.map(str::to_lowercase)
.filter(|term| {
!matches!(
term.as_str(),
"the"
| "and"
| "for"
| "with"
| "from"
| "that"
| "this"
| "find"
| "show"
| "all"
| "code"
| "files"
| "where"
| "what"
| "when"
| "which"
| "into"
)
})
.take(32)
.collect();
let mut seen = HashSet::new();
let files = files
.into_iter()
.filter(|file| seen.insert(file.file_name.clone()))
.filter_map(|file| excerpt(file, &terms))
.take(MAX_SEARCH_FILES)
.collect();
SearchCodebaseResult::Success { files }
}
fn excerpt(mut file: FileContext, terms: &[String]) -> Option<FileContext> {
let AnyFileContent::StringContent(content) = &file.content else {
return None;
};
// Also bound remote payloads before scoring. Never split a UTF-8 code point.
let mut scan_end = content.len().min(MAX_SEARCH_SCAN_BYTES);
while !content.is_char_boundary(scan_end) {
scan_end -= 1;
}
let lines: Vec<_> = content[..scan_end].split_inclusive('\n').collect();
if lines.is_empty() {
return None;
}
let mut best_line = 0;
let mut best_score = 0;
for (index, line) in lines.iter().enumerate() {
let lower = line.to_lowercase();
let score: usize = terms
.iter()
.filter(|term| lower.contains(term.as_str()))
.map(String::len)
.sum();
if score > best_score {
best_score = score;
best_line = index;
}
}
let mut start = best_line;
let mut preceding_bytes = 0;
while start > best_line.saturating_sub(5) {
let length = lines[start - 1].len();
if preceding_bytes + length > MAX_EXCERPT_BYTES / 3 {
break;
}
preceding_bytes += length;
start -= 1;
}
let mut text = String::new();
let mut line_count: usize = 0;
for line in lines.iter().skip(start).take(MAX_EXCERPT_LINES) {
let remaining = MAX_EXCERPT_BYTES - text.len();
if remaining == 0 {
break;
}
let mut end = line.len().min(remaining);
while !line.is_char_boundary(end) {
end -= 1;
}
if end == 0 {
break;
}
text.push_str(&line[..end]);
line_count += 1;
if end < line.len() {
break;
}
}
let first_line = file.line_range.as_ref().map_or(1, |range| range.start) + start;
file.line_range = Some(first_line..first_line + line_count.saturating_sub(1));
file.content = AnyFileContent::StringContent(text);
Some(file)
}
#[cfg(test)]
#[path = "search_codebase_context_tests.rs"]
mod tests;
@@ -0,0 +1,68 @@
use super::*;
fn file(name: &str, text: String) -> FileContext {
FileContext::new(
name.to_string(),
AnyFileContent::StringContent(text),
None,
None,
)
}
#[test]
fn search_returns_bounded_unique_files_with_valid_utf8() {
let files = (0..20)
.flat_map(|index| {
let file = file(&format!("{index}.rs"), "€".repeat(20_000));
[file.clone(), file]
})
.collect();
let SearchCodebaseResult::Success { files } =
bound_search_result(SearchCodebaseResult::Success { files }, "find models")
else {
panic!("expected success");
};
assert_eq!(files.len(), MAX_SEARCH_FILES);
let mut bytes = 0;
for file in files {
let AnyFileContent::StringContent(text) = file.content else {
panic!("expected text");
};
assert!(text.len() <= MAX_EXCERPT_BYTES);
assert_eq!(text.len() % 3, 0);
bytes += text.len();
}
assert!(bytes <= MAX_SEARCH_FILES * MAX_EXCERPT_BYTES);
}
#[test]
fn excerpt_finds_a_later_match_and_preserves_source_line_numbers() {
let text = format!(
"{}fn construct_chat_model() {{}}\n{}",
"// unrelated\n".repeat(200),
"// trailing\n".repeat(100)
);
let mut input = file("models.rs", text);
input.line_range = Some(101..401);
let output = excerpt(input, &["construct_chat_model".into()]).unwrap();
assert_eq!(output.line_range, Some(296..335));
let AnyFileContent::StringContent(text) = output.content else {
panic!("expected text");
};
assert!(text.contains("construct_chat_model"));
assert_eq!(text.lines().count(), MAX_EXCERPT_LINES);
}
#[test]
fn long_preceding_line_does_not_hide_the_relevant_match() {
let input = file(
"models.rs",
format!("{}\nfn target_model() {{}}\n", "x".repeat(10_000)),
);
let output = excerpt(input, &["target_model".into()]).unwrap();
assert_eq!(output.line_range, Some(2..2));
let AnyFileContent::StringContent(text) = output.content else {
panic!("expected text");
};
assert!(text.contains("target_model"));
}
@@ -1,3 +1,4 @@
use std::collections::HashMap;
#[cfg(not(target_family = "wasm"))]
use std::time::Duration;
@@ -13,12 +14,13 @@ use warpui::r#async::Timer;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
use crate::ai::blocklist::telemetry::{
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
@@ -32,6 +34,7 @@ const SEND_AGENT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(15);
pub struct SendMessageToAgentExecutor {
ambient_agent_task_id: Option<AmbientAgentTaskId>,
local_recovery_attempts: HashMap<AIConversationId, usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -121,6 +124,7 @@ impl SendMessageToAgentExecutor {
pub fn new() -> Self {
Self {
ambient_agent_task_id: None,
local_recovery_attempts: HashMap::new(),
}
}
@@ -159,6 +163,68 @@ impl SendMessageToAgentExecutor {
let subject = subject.clone();
let message_body = message.clone();
// Resolve only an actual child of this sender; local routing never trusts an arbitrary
// conversation ID as authority to resume another task.
let history = BlocklistAIHistoryModel::as_ref(ctx);
let local_children = history.child_conversations_of(conversation_id);
let local_child = if addresses.len() == 1 {
local_children.iter().find(|child| {
!child.is_remote_child()
&& (child.id().to_string() == addresses[0]
|| child.orchestration_agent_id().as_deref() == Some(addresses[0].as_str()))
})
} else {
None
};
if let Some(child) = local_child {
let child_id = child.id();
let error = if message_body.trim().is_empty() {
Some("A corrective instruction or follow-up message is required.")
} else if matches!(child.status(), ConversationStatus::Cancelled) {
Some("The child was cancelled. Ask the user to resume it; do not override cancellation.")
} else if matches!(child.status(), ConversationStatus::Blocked { .. }) {
Some("The child is blocked on permission or user input. Resolve that block rather than restarting it.")
} else if matches!(child.status(), ConversationStatus::Error)
&& self
.local_recovery_attempts
.get(&child_id)
.copied()
.unwrap_or(0)
>= 2
{
Some("This child has exhausted two orchestrator recovery attempts. Report the blocker to the user.")
} else {
None
};
if let Some(error) = error {
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(error.to_string()),
))
.into();
}
if matches!(child.status(), ConversationStatus::Error) {
*self.local_recovery_attempts.entry(child_id).or_default() += 1;
}
let message_id = OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
service.send_local_follow_up(conversation_id, child_id, message_body, ctx)
});
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Success { message_id },
))
.into();
}
if history
.conversation(&conversation_id)
.is_some_and(|parent| parent.run_id().is_none())
|| addresses.iter().any(|address| {
history.all_live_conversations().iter().any(|(_, target)| {
!target.is_remote_child() && target.id().to_string() == *address
})
})
{
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(SendMessageToAgentResult::Error("The target must be one existing child of this orchestrator. Use its agent_id from run_agents; do not message unrelated conversations.".to_string()))).into();
}
let (sender_run_id, task_id, task_resolution) =
sender_run_id_and_task_id_for_send(conversation_id, self.ambient_agent_task_id, ctx);
let log_addresses = addresses.clone();
@@ -313,6 +313,11 @@ impl StartAgentExecutor {
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let error_msg = if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
format!("Child agent {child_conversation_id} failed: {error_msg}. For a recoverable error (never a user cancellation), use send_message_to_agent with this agent_id and a corrective instruction to continue the same child; do not create a duplicate.")
} else {
error_msg
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
// Only startup acknowledgements may clean up a conversation that never
// initialized. Direct-provider completion waits preserve the terminal
+30 -2
View File
@@ -3948,6 +3948,11 @@ impl BlocklistAIController {
});
}
if has_piggybacked_events && result.is_ok() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
svc.confirm_local_messages(conversation_id);
});
}
self.pending_passive_follow_ups.remove(&conversation_id);
}
@@ -4102,8 +4107,13 @@ impl BlocklistAIController {
let is_ready_status = matches!(
conversation.status(),
ConversationStatus::Success | ConversationStatus::WaitingForEvents,
);
if !owns || has_active_stream || !is_ready_status {
) || (matches!(conversation.status(), ConversationStatus::Error)
&& OrchestrationEventService::as_ref(ctx).has_pending_local_message(conversation_id));
// A direct-provider wait can yield to a queued successor. send_request_input
// checkpoints that successor and cancels the old generation before resuming.
let provider_wait = self.active_provider_runs.contains_key(&conversation_id)
&& matches!(conversation.status(), ConversationStatus::WaitingForEvents);
if !owns || (has_active_stream && !provider_wait) || !is_ready_status {
log::info!(
"Pending events are not ready: conversation_id={conversation_id:?} owns_conversation={owns} has_active_stream={has_active_stream} status={:?}",
conversation.status()
@@ -4316,6 +4326,17 @@ impl BlocklistAIController {
return;
};
// A provider wait owns a durable run. Terminalize that generation before
// queuing the message turn, just as for a same-conversation follow-up.
if self.active_provider_runs.contains_key(&conversation_id) {
self.cancel_active_provider_run(
conversation_id,
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
ctx,
);
}
// The resume request supersedes any in-flight wait_for_events.
self.action_model.update(ctx, |action_model, ctx| {
action_model.cancel_wait_for_events_for_conversation(conversation_id, ctx);
@@ -4349,6 +4370,10 @@ impl BlocklistAIController {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.requeue_awaiting_events(conversation_id, ctx);
});
} else {
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
svc.confirm_local_messages(conversation_id);
});
}
}
@@ -8372,6 +8397,9 @@ impl BlocklistAIController {
});
self.maybe_refresh_ai_overages(ctx);
self.start_next_queued_provider_run(conversation_id, ctx);
// Status notifications may arrive before the stream releases its slot.
// Retry queued child messages now that terminal cleanup is complete.
self.handle_pending_events_ready(conversation_id, ctx);
}
fn start_next_queued_provider_run(
+164 -4
View File
@@ -47,6 +47,12 @@ impl LifecycleEventDetailStage {
/// Type-specific queued data.
#[derive(Debug, Clone)]
pub enum PendingEventDetail {
/// A locally authorized parent/child message; never sent through the hosted messaging API.
LocalMessage {
message_id: String,
subject: String,
message_body: String,
},
Message {
message_id: String,
addresses: Vec<String>,
@@ -90,6 +96,7 @@ pub struct OrchestrationEventService {
pending_events: HashMap<AIConversationId, Vec<PendingEvent>>,
awaiting_server_echo_events: HashMap<AIConversationId, Vec<PendingEvent>>,
conversation_statuses: HashMap<AIConversationId, ConversationStatus>,
restarted_children: HashSet<AIConversationId>,
}
impl OrchestrationEventService {
@@ -106,6 +113,7 @@ impl OrchestrationEventService {
pending_events: HashMap::new(),
awaiting_server_echo_events: HashMap::new(),
conversation_statuses: HashMap::new(),
restarted_children: HashSet::new(),
}
}
@@ -148,6 +156,7 @@ impl OrchestrationEventService {
self.pending_events.remove(conversation_id);
self.awaiting_server_echo_events.remove(conversation_id);
self.conversation_statuses.remove(conversation_id);
self.restarted_children.remove(conversation_id);
}
_ => {}
}
@@ -184,8 +193,12 @@ impl OrchestrationEventService {
conversation.status().clone()
};
self.conversation_statuses
let previous = self
.conversation_statuses
.insert(conversation_id, current_status.clone());
if !is_restored && previous.as_ref() != Some(&current_status) {
self.report_restarted_child(conversation_id, previous.as_ref(), &current_status, ctx);
}
let has_pending = self
.pending_events
.get(&conversation_id)
@@ -196,16 +209,148 @@ impl OrchestrationEventService {
// the stream finishes — either to `Success` or to
// `WaitingForEvents` via a `wait_for_events` yield.
if !is_restored
&& matches!(
&& (matches!(
&current_status,
ConversationStatus::Success | ConversationStatus::WaitingForEvents
)
) || (matches!(current_status, ConversationStatus::Error)
&& self.has_pending_local_message(conversation_id)))
&& has_pending
{
ctx.emit(OrchestrationEventServiceEvent::EventsReady { conversation_id });
}
}
pub fn has_pending_local_message(&self, conversation_id: AIConversationId) -> bool {
self.pending_events
.get(&conversation_id)
.is_some_and(|events| {
events
.iter()
.any(|event| matches!(event.detail, PendingEventDetail::LocalMessage { .. }))
})
}
/// Local providers commit the input directly and do not emit a hosted message echo.
pub fn confirm_local_messages(&mut self, conversation_id: AIConversationId) {
if let Some(events) = self.awaiting_server_echo_events.get_mut(&conversation_id) {
events.retain(|event| !matches!(event.detail, PendingEventDetail::LocalMessage { .. }));
if events.is_empty() {
self.awaiting_server_echo_events.remove(&conversation_id);
}
}
}
pub fn send_local_follow_up(
&mut self,
parent: AIConversationId,
child: AIConversationId,
message: String,
ctx: &mut ModelContext<Self>,
) -> String {
self.restarted_children.insert(child);
self.enqueue_local_message(
child,
parent,
"Follow-up from your orchestrator",
message,
ctx,
)
}
fn enqueue_local_message(
&mut self,
target: AIConversationId,
source: AIConversationId,
subject: &str,
message_body: String,
ctx: &mut ModelContext<Self>,
) -> String {
let message_id = Uuid::new_v4().to_string();
self.enqueue_event_batch(
target,
vec![PendingEvent {
event_id: message_id.clone(),
source_agent_id: source.to_string(),
attempt_count: 0,
detail: PendingEventDetail::LocalMessage {
message_id: message_id.clone(),
subject: subject.to_string(),
message_body,
},
}],
ctx,
);
message_id
}
fn report_restarted_child(
&mut self,
child: AIConversationId,
previous: Option<&ConversationStatus>,
status: &ConversationStatus,
ctx: &mut ModelContext<Self>,
) {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(&child) else {
return;
};
if conversation.is_remote_child() {
return;
}
let Some(parent) = conversation.parent_conversation_id() else {
return;
};
let restarting = matches!(
previous,
Some(
ConversationStatus::Error
| ConversationStatus::Cancelled
| ConversationStatus::Success
)
) && matches!(
status,
ConversationStatus::InProgress | ConversationStatus::TransientError
);
if restarting {
self.restarted_children.insert(child);
}
if !self.restarted_children.contains(&child) {
return;
}
let name = conversation.agent_name().unwrap_or("child agent");
let message = if restarting {
format!("Child {name} ({child}) has restarted. Its earlier terminal result is no longer the final outcome. Keep tracking this child; do not spawn a replacement. A completion update will follow.")
} else if matches!(status, ConversationStatus::Success) {
let output = conversation
.all_exchanges()
.into_iter()
.rev()
.filter_map(|exchange| exchange.output_status.output())
.map(|output| output.get().format_for_copy(None))
.find(|text| !text.is_empty())
.unwrap_or_default();
let mut end = output.len().min(16_000);
while !output.is_char_boundary(end) {
end -= 1;
}
format!("Restarted child {name} ({child}) completed successfully. Incorporate this result into your task, even if an earlier attempt failed.\n{}{}", &output[..end], if end < output.len() { "\n[Output excerpt truncated; inspect the child conversation for more.]" } else { "" })
} else if matches!(
status,
ConversationStatus::Error | ConversationStatus::Cancelled
) {
format!("Restarted child {name} ({child}) ended with {status:?}: {}. Diagnose the failure before retrying; do not loop or override a user cancellation.", conversation.status_error_message().unwrap_or_default())
} else {
return;
};
if matches!(
status,
ConversationStatus::Success | ConversationStatus::Error | ConversationStatus::Cancelled
) {
self.restarted_children.remove(&child);
}
self.enqueue_local_message(parent, child, "Child agent progress update", message, ctx);
}
fn enqueue_lifecycle_event(
&mut self,
target_conversation_id: AIConversationId,
@@ -305,6 +450,20 @@ impl OrchestrationEventService {
let mut server_bound_events = Vec::new();
for event in deliverable {
match &event.detail {
PendingEventDetail::LocalMessage {
message_id,
subject,
message_body,
} => {
messages.push(ReceivedMessageInput {
message_id: message_id.clone(),
sender_agent_id: event.source_agent_id.clone(),
addresses: vec![conversation_id.to_string()],
subject: subject.clone(),
message_body: message_body.clone(),
});
server_bound_events.push(event);
}
PendingEventDetail::Message {
message_id,
addresses,
@@ -625,7 +784,8 @@ fn did_event_round_trip_through_server(
echoed_lifecycle_event_ids: &HashSet<&str>,
) -> bool {
match &pending_event.detail {
PendingEventDetail::Message { message_id, .. } => {
PendingEventDetail::Message { message_id, .. }
| PendingEventDetail::LocalMessage { message_id, .. } => {
echoed_message_ids.contains(message_id.as_str())
}
PendingEventDetail::Lifecycle { event } => {
@@ -355,3 +355,39 @@ fn test_has_pending_events_tracks_any_event_kind() {
service.pending_events.remove(&conversation_id);
assert!(!service.has_pending_events(conversation_id));
}
#[test]
fn local_follow_up_is_delivered_once_and_acknowledged_without_server_echo() {
let conversation_id = AIConversationId::new();
let mut service = OrchestrationEventService::new_without_subscriptions();
service.pending_events.insert(
conversation_id,
vec![
PendingEvent {
event_id: "local-1".to_string(),
source_agent_id: "parent".to_string(),
attempt_count: 0,
detail: PendingEventDetail::LocalMessage {
message_id: "local-1".to_string(),
subject: "Correction".to_string(),
message_body: "Continue the existing task.".to_string(),
},
},
message_pending_event("hosted-1"),
],
);
assert!(service.has_pending_local_message(conversation_id));
let inputs = service.drain_and_convert_events(conversation_id);
let AIAgentInput::MessagesReceivedFromAgents { messages } = &inputs[0] else {
panic!("Expected child follow-up input");
};
assert_eq!(messages[0].sender_agent_id, "parent");
assert_eq!(messages[0].message_body, "Continue the existing task.");
assert_eq!(messages[0].addresses, vec![conversation_id.to_string()]);
assert!(service.drain_and_convert_events(conversation_id).is_empty());
service.confirm_local_messages(conversation_id);
let awaiting = &service.awaiting_server_echo_events[&conversation_id];
assert_eq!(awaiting.len(), 1);
assert_eq!(awaiting[0].event_id, "hosted-1");
assert!(!service.has_pending_local_message(conversation_id));
}