1use crate::agent::compact::{build_compact_view_cached, estimate_messages_tokens, CompactCache};
11use crate::config::AgentConfig;
12use crate::error::{AacodeError, Result};
13use crate::llm::types::{ChatMessage, ToolCall};
14use crate::llm::LlmClient;
15use crate::session::{SessionManager, SessionMessage};
16use crate::stream::{CollectingSink, EventSink};
17use crate::tools::ToolRegistry;
18use serde_json::Value;
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::Arc;
22use tokio::sync::Notify;
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum RunStatus {
27 Completed,
28 MaxIterations,
29 Cancelled,
30 Error(String),
31}
32
33impl RunStatus {
34 pub fn as_str(&self) -> &'static str {
37 match self {
38 RunStatus::Completed => "completed",
39 RunStatus::MaxIterations => "max_iterations",
40 RunStatus::Cancelled => "cancelled",
41 RunStatus::Error(_) => "error",
42 }
43 }
44}
45
46pub struct RunResult {
47 pub status: RunStatus,
48 pub iterations: u32,
49 pub final_text: String,
50}
51
52impl RunResult {
53 pub fn to_result_json(&self) -> serde_json::Value {
56 serde_json::json!({
57 "status": self.status.as_str(),
58 "iterations": self.iterations,
59 "final_text": self.final_text,
60 })
61 }
62}
63
64pub struct ReactLoop<'a> {
66 pub llm: &'a dyn LlmClient,
67 pub tools: &'a ToolRegistry,
68 pub config: &'a AgentConfig,
69 pub native_tools: Vec<Value>,
71}
72
73impl<'a> ReactLoop<'a> {
74 pub fn new(
75 llm: &'a dyn LlmClient,
76 tools: &'a ToolRegistry,
77 config: &'a AgentConfig,
78 native_tools: Vec<Value>,
79 ) -> Self {
80 ReactLoop {
81 llm,
82 tools,
83 config,
84 native_tools,
85 }
86 }
87
88 pub async fn run(
91 &self,
92 mut messages: Vec<ChatMessage>,
93 session: &mut SessionManager,
94 emitter: &dyn EventSink,
95 cancel: &AtomicBool,
96 ) -> Result<RunResult> {
97 let cancel_notify = Arc::new(Notify::new());
98 let mut stale = StaleTracker::default();
99 let mut compact_cache: Option<CompactCache> = None;
102 let session_id = session.current_session_id.clone().unwrap_or_default();
103 let mut last_text = String::new();
105
106 for iteration in 0..self.config.max_iterations {
107 if cancel.load(Ordering::SeqCst) {
108 emitter.error("cancelled");
109 return Ok(RunResult {
110 status: RunStatus::Cancelled,
111 iterations: iteration,
112 final_text: String::new(),
113 });
114 }
115
116 let (mut view, compacted, tokens) =
118 build_compact_view_cached(&messages, &self.config.context, &mut compact_cache);
119 if tokens > self.config.context.max_context_tokens {
123 return Err(AacodeError::Api(format!(
124 "context too large: {} tokens exceeds limit of {} ({} messages). Try reducing history or restarting the session.",
125 tokens,
126 self.config.context.max_context_tokens,
127 messages.len(),
128 )));
129 }
130 if compacted {
134 crate::agent::sanitize::sanitize_history(view.to_mut());
135 }
136
137 let resp = match self
138 .chat_with_retry(&view, emitter, cancel, &cancel_notify)
139 .await
140 {
141 Ok(r) => r,
142 Err(AacodeError::Cancelled) => {
143 emitter.error("cancelled");
144 return Ok(RunResult {
145 status: RunStatus::Cancelled,
146 iterations: iteration,
147 final_text: String::new(),
148 });
149 }
150 Err(e) => {
151 let classified = classify_api_error(&e);
152 emitter.error(&classified);
153 return Ok(RunResult {
154 status: RunStatus::Error(classified),
155 iterations: iteration,
156 final_text: String::new(),
157 });
158 }
159 };
160
161 last_text = resp.text.clone();
162
163 if resp.tool_calls.is_empty() {
165 if resp.is_truncated() {
169 let assistant = ChatMessage {
170 role: "assistant".into(),
171 content: last_text.clone(),
172 tool_calls: None,
173 tool_call_id: None,
174 reasoning_content: resp.reasoning_content.clone(),
175 };
176 messages.push(assistant.clone());
177 let _ = session.add_message(SessionMessage::from_chat(&assistant));
178 let cont = ChatMessage::user("continue");
179 messages.push(cont.clone());
180 let _ = session.add_message(SessionMessage::from_chat(&cont));
181 continue;
182 }
183
184 let assistant = ChatMessage {
185 role: "assistant".into(),
186 content: last_text.clone(),
187 tool_calls: None,
188 tool_call_id: None,
189 reasoning_content: resp.reasoning_content.clone(),
190 };
191 messages.push(assistant.clone());
192 let _ = session.add_message(SessionMessage::from_chat(&assistant));
193 let _ = session.flush();
196 emitter.done_result(&session_id, "completed", iteration + 1, &last_text);
197 return Ok(RunResult {
198 status: RunStatus::Completed,
199 iterations: iteration + 1,
200 final_text: last_text,
201 });
202 }
203
204 if let Some(w) = stale.detect(&resp.tool_calls) {
207 let warn_msg = ChatMessage::system(format!("[SYSTEM WARNING]: {w}"));
208 let _ = session.add_message(SessionMessage::from_chat(&warn_msg));
209 messages.push(warn_msg);
210 }
211
212 let assistant =
214 ChatMessage::assistant_with_tools(resp.text.clone(), resp.tool_calls.clone());
215 let mut assistant_msg = assistant.clone();
216 assistant_msg.reasoning_content = resp.reasoning_content.clone();
217 messages.push(assistant_msg.clone());
218 let _ = session.add_message(SessionMessage::from_chat(&assistant_msg));
219
220 let tool_count = resp.tool_calls.len();
222 if tool_count > 1 {
223 use futures::future::join_all;
224 let cancel_arc = Arc::new(AtomicBool::new(false));
225 let cancel_ref = cancel as &AtomicBool; let futures: Vec<_> = resp
228 .tool_calls
229 .iter()
230 .map(|tc| {
231 let name = tc.name.clone();
232 let args = tc.parsed_args();
233 let cancel = cancel_arc.clone();
234 async move {
235 if cancel_ref.load(Ordering::SeqCst) || cancel.load(Ordering::SeqCst) {
236 return (tc.id.clone(), tc.name.clone(), "cancelled".to_string());
237 }
238 let obs = self
239 .execute_with_retry(&name, args, emitter, cancel.as_ref())
240 .await;
241 (tc.id.clone(), tc.name.clone(), obs)
242 }
243 })
244 .collect();
245
246 let results = join_all(futures).await;
247
248 for (tc_id, tc_name, observation) in results {
249 if cancel.load(Ordering::SeqCst) {
250 emitter.error("cancelled");
251 return Ok(RunResult {
252 status: RunStatus::Cancelled,
253 iterations: iteration + 1,
254 final_text: String::new(),
255 });
256 }
257 if tc_name == "fetch_url" {
258 let url = resp
259 .tool_calls
260 .iter()
261 .find(|tc| tc.id == tc_id)
262 .and_then(|tc| {
263 tc.parsed_args()
264 .get("url")
265 .and_then(|v| v.as_str())
266 .map(|s| s.to_string())
267 })
268 .unwrap_or_default();
269 stale.record_fetch(&url, &observation);
270 }
271 emitter.seg_observation(&observation, self.config.limits.display_preview_chars);
272 let tool_msg = ChatMessage::tool_result(tc_id, observation);
273 messages.push(tool_msg.clone());
274 let _ = session.add_message(SessionMessage::from_chat(&tool_msg));
275 }
276 } else {
277 for tc in &resp.tool_calls {
279 if cancel.load(Ordering::SeqCst) {
280 emitter.error("cancelled");
281 return Ok(RunResult {
282 status: RunStatus::Cancelled,
283 iterations: iteration + 1,
284 final_text: String::new(),
285 });
286 }
287 let args = tc.parsed_args();
288 let observation = self
289 .execute_with_retry(&tc.name, args, emitter, cancel)
290 .await;
291 if tc.name == "fetch_url" {
292 let url = tc
293 .parsed_args()
294 .get("url")
295 .and_then(|v| v.as_str())
296 .unwrap_or("")
297 .to_string();
298 stale.record_fetch(&url, &observation);
299 }
300 emitter.seg_observation(&observation, self.config.limits.display_preview_chars);
301 let tool_msg = ChatMessage::tool_result(tc.id.clone(), observation);
302 messages.push(tool_msg.clone());
303 let _ = session.add_message(SessionMessage::from_chat(&tool_msg));
304 }
305 }
306
307 let _ = estimate_messages_tokens(&messages);
309 }
310
311 let _ = session.flush();
312 emitter.done_result(
313 &session_id,
314 "max_iterations",
315 self.config.max_iterations,
316 &last_text,
317 );
318 Ok(RunResult {
319 status: RunStatus::MaxIterations,
320 iterations: self.config.max_iterations,
321 final_text: last_text,
322 })
323 }
324
325 async fn chat_with_retry(
327 &self,
328 view: &[ChatMessage],
329 emitter: &dyn EventSink,
330 cancel: &AtomicBool,
331 cancel_notify: &Notify,
332 ) -> Result<crate::llm::types::LlmResponse> {
333 let max = self.config.limits.max_retries.max(1);
334 let mut last: Option<AacodeError> = None;
335 for attempt in 0..max {
336 if cancel.load(Ordering::SeqCst) {
337 return Err(AacodeError::Cancelled);
338 }
339 let use_buffered = attempt > 0 && !emitter.is_tty();
347 let (result, buffered): (_, Option<CollectingSink>) = if use_buffered {
350 let b = CollectingSink::new(false);
351 let fut = self.llm.chat_stream(view, &self.native_tools, &b, cancel);
352 let r = tokio::select! {
353 r = fut => r,
354 _ = cancel_checker(cancel, cancel_notify) => Err(AacodeError::Cancelled),
355 };
356 (r, Some(b))
357 } else {
358 let fut = self
359 .llm
360 .chat_stream(view, &self.native_tools, emitter, cancel);
361 let r = tokio::select! {
362 r = fut => r,
363 _ = cancel_checker(cancel, cancel_notify) => Err(AacodeError::Cancelled),
364 };
365 (r, None)
366 };
367
368 match result {
369 Ok(r) => {
370 if let Some(collected) = buffered {
371 for line in collected.lines() {
372 emitter.emit_line(&line);
373 }
374 }
375 return Ok(r);
376 }
377 Err(AacodeError::Cancelled) => return Err(AacodeError::Cancelled),
378 Err(e) => {
379 let retryable = e.is_retryable();
380 if retryable && attempt + 1 < max {
384 emitter.tool_progress(
385 "building",
386 &format!("llm retry {}/{} ({})", attempt + 2, max, brief(&e)),
387 0,
388 );
389 }
390 last = Some(e);
391 if !retryable || attempt + 1 >= max {
392 break;
393 }
394 let delay = 1u64 << attempt.min(4);
397 let jitter = {
398 let nanos = std::time::SystemTime::now()
399 .duration_since(std::time::UNIX_EPOCH)
400 .unwrap_or_default()
401 .subsec_nanos() as u64;
402 let half_range = (delay * 250).max(1); nanos % (half_range * 2).max(1)
404 };
405 let actual = if jitter > 0 {
406 (delay * 1000 + jitter - delay * 250).max(100)
407 } else {
408 delay * 1000
409 };
410 tokio::time::sleep(std::time::Duration::from_millis(actual)).await;
411 }
412 }
413 }
414 Err(last.unwrap_or_else(|| AacodeError::Api("model call failed".into())))
415 }
416
417 async fn execute_with_retry(
419 &self,
420 name: &str,
421 args: Value,
422 _emitter: &dyn EventSink,
423 cancel: &AtomicBool,
424 ) -> String {
425 let max_retries = self.config.limits.max_retries.max(1);
426 let mut last = String::new();
427 for attempt in 0..max_retries {
428 if cancel.load(Ordering::SeqCst) {
429 return "cancelled".to_string();
430 }
431 let notify = Notify::new();
437 let obs = tokio::select! {
438 obs = self.tools.execute(name, args.clone(), cancel) => obs,
439 _ = cancel_checker(cancel, ¬ify) => return "cancelled".to_string(),
440 };
441 let lower = obs.to_lowercase();
442 let retryable = lower.contains("timeout")
443 || lower.contains("timed out")
444 || lower.contains("connection")
445 || lower.contains("temporary")
446 || lower.contains("network");
447 last = obs;
448 if !retryable || attempt + 1 >= max_retries {
449 break;
450 }
451 }
452 last
453 }
454}
455
456fn classify_api_error(e: &AacodeError) -> String {
458 let s = e.to_string();
459 let l = s.to_lowercase();
460 if l.contains("401") || l.contains("authentication") || l.contains("invalid api key") {
461 format!("API authentication failed (check LLM_API_KEY / endpoint): {s}")
462 } else if l.contains("quota") || l.contains("rate limit") || l.contains("429") {
463 format!("API quota/rate limit reached (check usage or wait): {s}")
464 } else if l.contains("connection") || l.contains("timeout") || l.contains("network") {
465 format!("Network error reaching the model (check connectivity/endpoint): {s}")
466 } else if l.contains("config") || l.contains("api key not configured") {
467 format!("Configuration error: {s}")
468 } else {
469 s
470 }
471}
472
473#[derive(Default)]
475struct StaleTracker {
476 by_domain: HashMap<String, Vec<bool>>, warned: std::collections::HashSet<String>,
478}
479
480impl StaleTracker {
481 fn detect(&mut self, tool_calls: &[ToolCall]) -> Option<String> {
482 if !tool_calls.iter().any(|tc| tc.name == "fetch_url") {
484 return None;
485 }
486 let mut candidate: Option<String> = None;
488 for (domain, entries) in &self.by_domain {
489 if entries.len() < 3 {
490 continue;
491 }
492 let last3 = &entries[entries.len() - 3..];
493 if last3.iter().all(|has| !has) {
494 let key = format!("stale_{domain}");
495 if !self.warned.contains(&key) {
496 candidate = Some(domain.clone());
497 break;
498 }
499 }
500 }
501 let domain = candidate?;
502 self.warned.insert(format!("stale_{domain}"));
503 Some(format!(
504 "Last 3 fetch_url calls to '{domain}' returned unreadable content. Try search_web or proceed with existing knowledge."
505 ))
506 }
507
508 fn record_fetch(&mut self, url: &str, observation: &str) {
509 let domain = domain_of(url);
510 let text: String = observation
512 .chars()
513 .filter(|c| *c != '<' && *c != '>')
514 .collect();
515 let has_content = text.trim().len() >= 200;
516 let e = self.by_domain.entry(domain).or_default();
517 e.push(has_content);
518 if e.len() > 5 {
519 let drain = e.len() - 5;
520 e.drain(0..drain);
521 }
522 }
523}
524
525fn domain_of(url: &str) -> String {
526 let after_scheme = url.split("://").nth(1).unwrap_or(url);
528 after_scheme
529 .split('/')
530 .next()
531 .unwrap_or(after_scheme)
532 .to_string()
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::config::AgentConfig;
539 use crate::llm::types::LlmResponse;
540 use crate::stream::CollectingSink;
541 use crate::tools::registry::Tool;
542 use crate::tools::schema::{ParamType, ToolParameter, ToolSchema};
543 use std::sync::Mutex;
544
545 struct ScriptedLlm {
547 responses: Mutex<Vec<LlmResponse>>,
548 }
549 #[async_trait::async_trait]
550 impl LlmClient for ScriptedLlm {
551 async fn chat_stream(
552 &self,
553 _m: &[ChatMessage],
554 _t: &[Value],
555 emitter: &dyn EventSink,
556 _c: &AtomicBool,
557 ) -> Result<LlmResponse> {
558 let mut q = self.responses.lock().unwrap();
559 if q.is_empty() {
560 return Err(AacodeError::Api("no more scripted responses".into()));
561 }
562 let r = q.remove(0);
563 emitter.seg("thought", &r.text);
564 Ok(r)
565 }
566 async fn validate(&self) -> Result<()> {
567 Ok(())
568 }
569 }
570
571 struct RecordingLlm {
573 responses: Mutex<Vec<LlmResponse>>,
574 seen: Mutex<Vec<Vec<ChatMessage>>>,
575 }
576 #[async_trait::async_trait]
577 impl LlmClient for RecordingLlm {
578 async fn chat_stream(
579 &self,
580 m: &[ChatMessage],
581 _t: &[Value],
582 emitter: &dyn EventSink,
583 _c: &AtomicBool,
584 ) -> Result<LlmResponse> {
585 self.seen.lock().unwrap().push(m.to_vec());
586 let mut q = self.responses.lock().unwrap();
587 if q.is_empty() {
588 return Err(AacodeError::Api("no more scripted responses".into()));
589 }
590 let r = q.remove(0);
591 emitter.seg("thought", &r.text);
592 Ok(r)
593 }
594 async fn validate(&self) -> Result<()> {
595 Ok(())
596 }
597 }
598
599 struct EchoTool;
600 #[async_trait::async_trait]
601 impl Tool for EchoTool {
602 fn schema(&self) -> ToolSchema {
603 ToolSchema::new(
604 "echo",
605 "echo",
606 vec![ToolParameter::new(
607 "text",
608 ParamType::String,
609 true,
610 "t",
611 &[],
612 )],
613 )
614 }
615 async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
616 Ok(format!("echoed: {}", args["text"].as_str().unwrap_or("")))
617 }
618 }
619
620 fn tmp_proj() -> std::path::PathBuf {
621 let d = std::env::temp_dir().join(format!(
622 "aacode_react_{}_{}",
623 std::process::id(),
624 uuid::Uuid::new_v4().simple()
625 ));
626 std::fs::create_dir_all(&d).unwrap();
627 d
628 }
629
630 fn tc(name: &str, args: &str) -> ToolCall {
631 ToolCall {
632 id: "c1".into(),
633 name: name.into(),
634 arguments: args.into(),
635 }
636 }
637
638 #[tokio::test]
639 async fn completes_when_no_tool_calls() {
640 let llm = ScriptedLlm {
641 responses: Mutex::new(vec![LlmResponse {
642 text: "all done".into(),
643 ..Default::default()
644 }]),
645 };
646 let mut reg = ToolRegistry::new();
647 reg.register(Box::new(EchoTool));
648 let cfg = AgentConfig::default();
649 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
650
651 let proj = tmp_proj();
652 let mut sm = SessionManager::new(&proj);
653 sm.create_session("t", None).unwrap();
654 let sink = CollectingSink::new(false);
655 let cancel = AtomicBool::new(false);
656
657 let msgs = vec![ChatMessage::system("sys"), ChatMessage::user("hi")];
658 let res = loop_.run(msgs, &mut sm, &sink, &cancel).await.unwrap();
659 assert_eq!(res.status, RunStatus::Completed);
660 assert_eq!(res.final_text, "all done");
661 assert!(sink.lines().iter().any(|l| l.contains(r#""type":"done""#)));
662 }
663
664 #[tokio::test]
665 async fn executes_tool_then_completes() {
666 let llm = ScriptedLlm {
667 responses: Mutex::new(vec![
668 LlmResponse {
669 text: "let me echo".into(),
670 tool_calls: vec![tc("echo", "{\"text\":\"hey\"}")],
671 ..Default::default()
672 },
673 LlmResponse {
674 text: "done".into(),
675 ..Default::default()
676 },
677 ]),
678 };
679 let mut reg = ToolRegistry::new();
680 reg.register(Box::new(EchoTool));
681 let cfg = AgentConfig::default();
682 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
683
684 let proj = tmp_proj();
685 let mut sm = SessionManager::new(&proj);
686 sm.create_session("t", None).unwrap();
687 let sink = CollectingSink::new(false);
688 let cancel = AtomicBool::new(false);
689
690 let msgs = vec![ChatMessage::system("sys"), ChatMessage::user("echo hey")];
691 let res = loop_.run(msgs, &mut sm, &sink, &cancel).await.unwrap();
692 assert_eq!(res.status, RunStatus::Completed);
693 assert!(sink.lines().iter().any(|l| l.contains("echoed: hey")));
695 assert!(sm.messages.iter().any(|m| m.tool_calls.is_some()));
697 assert!(sm.messages.iter().any(|m| m.role == "tool"));
698 }
699
700 #[tokio::test]
701 async fn persists_turn_messages_without_segment_duplication() {
702 let llm = ScriptedLlm {
703 responses: Mutex::new(vec![
704 LlmResponse {
705 text: "let me echo".into(),
706 tool_calls: vec![tc("echo", "{\"text\":\"hey\"}")],
707 reasoning_content: Some("I need to echo".into()),
708 ..Default::default()
709 },
710 LlmResponse {
711 text: "all done".into(),
712 reasoning_content: Some("summarize".into()),
713 ..Default::default()
714 },
715 ]),
716 };
717 let mut reg = ToolRegistry::new();
718 reg.register(Box::new(EchoTool));
719 let cfg = AgentConfig::default();
720 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
721
722 let proj = tmp_proj();
723 let mut sm = SessionManager::new(&proj);
724 sm.create_session("t", None).unwrap();
725 let sink = CollectingSink::new(false);
726 let cancel = AtomicBool::new(false);
727
728 let msgs = vec![ChatMessage::system("sys"), ChatMessage::user("echo hey")];
729 let res = loop_.run(msgs, &mut sm, &sink, &cancel).await.unwrap();
730 assert_eq!(res.status, RunStatus::Completed);
731
732 let tool_asst = sm
736 .messages
737 .iter()
738 .find(|m| m.tool_calls.is_some())
739 .expect("assistant with tool_calls present");
740 assert_eq!(
741 tool_asst.reasoning_content.as_deref(),
742 Some("I need to echo")
743 );
744 assert_eq!(tool_asst.content, "let me echo");
745 assert_eq!(tool_asst.tool_calls.as_ref().unwrap()[0].name, "echo");
746
747 let tool_msg = sm
748 .messages
749 .iter()
750 .find(|m| m.role == "tool")
751 .expect("tool result present");
752 assert!(tool_msg.content.contains("echoed: hey"));
753
754 let final_asst = sm
755 .messages
756 .iter()
757 .rev()
758 .find(|m| m.role == "assistant")
759 .expect("final assistant present");
760 assert_eq!(final_asst.reasoning_content.as_deref(), Some("summarize"));
761 assert_eq!(final_asst.content, "all done");
762 }
763
764 #[tokio::test]
768 async fn llm_request_has_exactly_one_copy_of_each_tool_message() {
769 let llm = RecordingLlm {
770 responses: Mutex::new(vec![
771 LlmResponse {
772 text: "let me echo".into(),
773 tool_calls: vec![tc("echo", "{\"text\":\"hey\"}")],
774 reasoning_content: Some("I need to echo".into()),
775 ..Default::default()
776 },
777 LlmResponse {
778 text: "all done".into(),
779 reasoning_content: Some("summarize".into()),
780 ..Default::default()
781 },
782 ]),
783 seen: Mutex::new(vec![]),
784 };
785 let mut reg = ToolRegistry::new();
786 reg.register(Box::new(EchoTool));
787 let cfg = AgentConfig::default();
788 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
789
790 let proj = tmp_proj();
791 let mut sm = SessionManager::new(&proj);
792 sm.create_session("t", None).unwrap();
793 let sink = CollectingSink::new(false);
794 let cancel = AtomicBool::new(false);
795 let msgs = vec![ChatMessage::system("sys"), ChatMessage::user("echo hey")];
796 let res = loop_.run(msgs, &mut sm, &sink, &cancel).await.unwrap();
797 assert_eq!(res.status, RunStatus::Completed);
798
799 let seen = llm.seen.lock().unwrap();
800 assert_eq!(seen.len(), 2, "one request per model turn");
801 let second = &seen[1];
802
803 let with_calls: Vec<&ChatMessage> =
804 second.iter().filter(|m| m.tool_calls.is_some()).collect();
805 assert_eq!(with_calls.len(), 1, "exactly one assistant tool_calls msg");
806 let tool_msgs: Vec<&ChatMessage> = second.iter().filter(|m| m.role == "tool").collect();
807 assert_eq!(
808 tool_msgs.len(),
809 1,
810 "exactly one tool result (no duplication)"
811 );
812 assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("c1"));
813 assert_eq!(tool_msgs[0].content, "echoed: hey");
814
815 assert_eq!(with_calls[0].content, "let me echo");
816 assert_eq!(
817 with_calls[0].reasoning_content.as_deref(),
818 Some("I need to echo")
819 );
820 let ai = second.iter().position(|m| m.tool_calls.is_some()).unwrap();
821 assert_eq!(second[ai + 1].role, "tool", "result adjacent to its call");
822
823 assert_eq!(second.iter().filter(|m| m.role == "user").count(), 1);
825 assert_eq!(second.iter().filter(|m| m.role == "system").count(), 1);
826
827 assert_eq!(sm.history_chat().len(), sm.messages.len());
829 }
830
831 #[tokio::test]
832 async fn respects_cancel_before_start() {
833 let llm = ScriptedLlm {
834 responses: Mutex::new(vec![]),
835 };
836 let reg = ToolRegistry::new();
837 let cfg = AgentConfig::default();
838 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
839 let proj = tmp_proj();
840 let mut sm = SessionManager::new(&proj);
841 sm.create_session("t", None).unwrap();
842 let sink = CollectingSink::new(false);
843 let cancel = AtomicBool::new(true);
844 let res = loop_
845 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
846 .await
847 .unwrap();
848 assert_eq!(res.status, RunStatus::Cancelled);
849 }
850
851 #[tokio::test]
852 async fn max_iterations_reached() {
853 let responses: Vec<LlmResponse> = (0..5)
855 .map(|_| LlmResponse {
856 text: "again".into(),
857 tool_calls: vec![tc("echo", "{\"text\":\"x\"}")],
858 ..Default::default()
859 })
860 .collect();
861 let llm = ScriptedLlm {
862 responses: Mutex::new(responses),
863 };
864 let mut reg = ToolRegistry::new();
865 reg.register(Box::new(EchoTool));
866 let mut cfg = AgentConfig::default();
867 cfg.max_iterations = 3;
868 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
869 let proj = tmp_proj();
870 let mut sm = SessionManager::new(&proj);
871 sm.create_session("t", None).unwrap();
872 let sink = CollectingSink::new(false);
873 let cancel = AtomicBool::new(false);
874 let res = loop_
875 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
876 .await
877 .unwrap();
878 assert_eq!(res.status, RunStatus::MaxIterations);
879 assert_eq!(res.iterations, 3);
880 }
881
882 #[tokio::test]
883 async fn domain_parse() {
884 assert_eq!(domain_of("https://example.com/path"), "example.com");
885 assert_eq!(domain_of("http://a.b.c/x/y"), "a.b.c");
886 }
887
888 #[tokio::test]
889 async fn classify_errors() {
890 assert!(
891 classify_api_error(&AacodeError::Api("HTTP 401 no".into())).contains("authentication")
892 );
893 assert!(classify_api_error(&AacodeError::Api("rate limit".into())).contains("quota"));
894 assert!(classify_api_error(&AacodeError::Network("reset".into())).contains("Network"));
895 assert!(classify_api_error(&AacodeError::Config("no key".into())).contains("Configuration"));
896 }
897
898 struct FlakyLlm {
900 remaining_failures: Mutex<u32>,
901 }
902 #[async_trait::async_trait]
903 impl LlmClient for FlakyLlm {
904 async fn chat_stream(
905 &self,
906 _m: &[ChatMessage],
907 _t: &[Value],
908 _e: &dyn EventSink,
909 _c: &AtomicBool,
910 ) -> Result<LlmResponse> {
911 let mut n = self.remaining_failures.lock().unwrap();
912 if *n > 0 {
913 *n -= 1;
914 return Err(AacodeError::Network("temporary reset".into()));
915 }
916 Ok(LlmResponse {
917 text: "recovered".into(),
918 ..Default::default()
919 })
920 }
921 async fn validate(&self) -> Result<()> {
922 Ok(())
923 }
924 }
925
926 #[tokio::test]
927 async fn retries_transient_llm_errors() {
928 let llm = FlakyLlm {
929 remaining_failures: Mutex::new(1),
930 };
931 let reg = ToolRegistry::new();
932 let mut cfg = AgentConfig::default();
933 cfg.limits.max_retries = 3;
934 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
935 let sink = CollectingSink::new(false);
936 let cancel = AtomicBool::new(false);
937 let proj = tmp_proj();
939 let mut sm = SessionManager::new(&proj);
940 sm.create_session("t", None).unwrap();
941 let res = loop_
942 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
943 .await
944 .unwrap();
945 assert_eq!(res.status, RunStatus::Completed);
946 assert_eq!(res.final_text, "recovered");
947 }
948
949 #[tokio::test]
950 async fn truncated_response_not_completed() {
951 let responses: Vec<LlmResponse> = (0..5)
955 .map(|_| LlmResponse {
956 text: "partial".into(),
957 tool_calls: vec![],
958 finish_reason: Some("length".to_string()),
959 ..Default::default()
960 })
961 .collect();
962 let llm = ScriptedLlm {
963 responses: Mutex::new(responses),
964 };
965 let mut reg = ToolRegistry::new();
966 reg.register(Box::new(EchoTool));
967 let mut cfg = AgentConfig::default();
968 cfg.max_iterations = 5;
969 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
970 let proj = tmp_proj();
971 let mut sm = SessionManager::new(&proj);
972 sm.create_session("t", None).unwrap();
973 let sink = CollectingSink::new(false);
974 let cancel = AtomicBool::new(false);
975 let res = loop_
976 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
977 .await
978 .unwrap();
979 assert_eq!(
982 res.status,
983 RunStatus::MaxIterations,
984 "truncated response must not be treated as completed"
985 );
986 }
987
988 #[tokio::test]
989 async fn max_iterations_returns_last_text() {
990 let responses: Vec<LlmResponse> = (0..5)
991 .map(|i| LlmResponse {
992 text: format!("iter {}", i),
993 tool_calls: vec![tc("echo", "{\"text\":\"x\"}")],
994 ..Default::default()
995 })
996 .collect();
997 let llm = ScriptedLlm {
998 responses: Mutex::new(responses),
999 };
1000 let mut reg = ToolRegistry::new();
1001 reg.register(Box::new(EchoTool));
1002 let mut cfg = AgentConfig::default();
1003 cfg.max_iterations = 2;
1004 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1005 let proj = tmp_proj();
1006 let mut sm = SessionManager::new(&proj);
1007 sm.create_session("t", None).unwrap();
1008 let sink = CollectingSink::new(false);
1009 let cancel = AtomicBool::new(false);
1010 let res = loop_
1011 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1012 .await
1013 .unwrap();
1014 assert_eq!(res.status, RunStatus::MaxIterations);
1015 assert!(
1016 !res.final_text.is_empty(),
1017 "max_iterations must return last response text"
1018 );
1019 }
1020
1021 struct FailThenStreamLlm {
1025 remaining_failures: Mutex<u32>,
1026 }
1027 #[async_trait::async_trait]
1028 impl LlmClient for FailThenStreamLlm {
1029 async fn chat_stream(
1030 &self,
1031 _m: &[ChatMessage],
1032 _t: &[Value],
1033 emitter: &dyn EventSink,
1034 _c: &AtomicBool,
1035 ) -> Result<LlmResponse> {
1036 let mut n = self.remaining_failures.lock().unwrap();
1037 if *n > 0 {
1038 *n -= 1;
1039 return Err(AacodeError::Network("temporary reset".into()));
1040 }
1041 emitter.delta("thought", "streaming");
1042 emitter.seg("thinking", "reasoning done");
1043 emitter.seg("thought", "complete response");
1044 Ok(LlmResponse {
1045 text: "complete response".into(),
1046 ..Default::default()
1047 })
1048 }
1049 async fn validate(&self) -> Result<()> {
1050 Ok(())
1051 }
1052 }
1053
1054 struct EmitThenFailLlm {
1058 call: Mutex<u32>,
1059 }
1060 #[async_trait::async_trait]
1061 impl LlmClient for EmitThenFailLlm {
1062 async fn chat_stream(
1063 &self,
1064 _m: &[ChatMessage],
1065 _t: &[Value],
1066 emitter: &dyn EventSink,
1067 _c: &AtomicBool,
1068 ) -> Result<LlmResponse> {
1069 let mut call = self.call.lock().unwrap();
1070 *call += 1;
1071 if *call == 1 {
1072 emitter.delta("thought", "partial tok");
1074 emitter.seg("thinking", "partial reasoning");
1075 emitter.seg("thought", "partial text");
1076 return Err(AacodeError::Network("mid-stream disconnect".into()));
1077 }
1078 emitter.delta("thought", "complete");
1080 emitter.seg("thinking", "full reasoning");
1081 emitter.seg("thought", "final complete text");
1082 Ok(LlmResponse {
1083 text: "final complete text".into(),
1084 ..Default::default()
1085 })
1086 }
1087 async fn validate(&self) -> Result<()> {
1088 Ok(())
1089 }
1090 }
1091
1092 struct AlwaysFailLlm;
1094 #[async_trait::async_trait]
1095 impl LlmClient for AlwaysFailLlm {
1096 async fn chat_stream(
1097 &self,
1098 _m: &[ChatMessage],
1099 _t: &[Value],
1100 _emitter: &dyn EventSink,
1101 _c: &AtomicBool,
1102 ) -> Result<LlmResponse> {
1103 Err(AacodeError::Network("always fails".into()))
1104 }
1105 async fn validate(&self) -> Result<()> {
1106 Ok(())
1107 }
1108 }
1109
1110 struct TimeoutTool;
1112 #[async_trait::async_trait]
1113 impl Tool for TimeoutTool {
1114 fn schema(&self) -> ToolSchema {
1115 ToolSchema::new(
1116 "timeout_cmd",
1117 "timeout",
1118 vec![ToolParameter::new("x", ParamType::String, true, "x", &[])],
1119 )
1120 }
1121 async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
1122 Ok("command timed out after 10s".to_string())
1123 }
1124 }
1125
1126 struct CountingTool {
1128 count: std::sync::Arc<Mutex<u32>>,
1129 tool_name: &'static str,
1130 response: String,
1131 }
1132 impl CountingTool {
1133 fn new(name: &'static str, response: String) -> (Self, std::sync::Arc<Mutex<u32>>) {
1134 let count = std::sync::Arc::new(Mutex::new(0u32));
1135 (
1136 CountingTool {
1137 count: count.clone(),
1138 tool_name: name,
1139 response,
1140 },
1141 count,
1142 )
1143 }
1144 }
1145 #[async_trait::async_trait]
1146 impl Tool for CountingTool {
1147 fn schema(&self) -> ToolSchema {
1148 ToolSchema::new(self.tool_name, self.tool_name, vec![])
1149 }
1150 async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
1151 *self.count.lock().unwrap() += 1;
1152 Ok(self.response.clone())
1153 }
1154 }
1155
1156 #[tokio::test]
1157 async fn execute_with_retry_retries_on_network() {
1158 let mut reg = ToolRegistry::new();
1159 let (tool, counter) = CountingTool::new(
1160 "understand_image",
1161 r#"{"success":false,"error":"network error: vision: error sending request for url (https://api.moonshot.cn/v1/chat/completions)"}"#.to_string(),
1162 );
1163 reg.register(Box::new(tool));
1164 let mut cfg = AgentConfig::default();
1165 cfg.limits.max_retries = 3;
1166 cfg.max_iterations = 1;
1167 let responses = vec![LlmResponse {
1168 text: "try".into(),
1169 tool_calls: vec![tc(
1170 "understand_image",
1171 r#"{"image_path":"a.jpg","prompt":"desc"}"#,
1172 )],
1173 ..Default::default()
1174 }];
1175 let llm = ScriptedLlm {
1176 responses: Mutex::new(responses),
1177 };
1178 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1179 let proj = tmp_proj();
1180 let mut sm = SessionManager::new(&proj);
1181 sm.create_session("t", None).unwrap();
1182 let sink = CollectingSink::new(false);
1183 let cancel = AtomicBool::new(false);
1184 let _ = loop_
1185 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1186 .await;
1187 let invocations = *counter.lock().unwrap();
1188 assert!(
1189 invocations >= 2,
1190 "network error must trigger retry, got {invocations} invocations"
1191 );
1192 }
1193
1194 #[tokio::test]
1195 async fn execute_with_retry_does_not_retry_on_config_error() {
1196 let mut reg = ToolRegistry::new();
1197 let (tool, counter) = CountingTool::new(
1198 "understand_image",
1199 r#"{"success":false,"error":"config error: no multimodal model configured"}"#
1200 .to_string(),
1201 );
1202 reg.register(Box::new(tool));
1203 let mut cfg = AgentConfig::default();
1204 cfg.limits.max_retries = 3;
1205 cfg.max_iterations = 1;
1206 let responses = vec![LlmResponse {
1207 text: "try".into(),
1208 tool_calls: vec![tc(
1209 "understand_image",
1210 r#"{"image_path":"a.jpg","prompt":"desc"}"#,
1211 )],
1212 ..Default::default()
1213 }];
1214 let llm = ScriptedLlm {
1215 responses: Mutex::new(responses),
1216 };
1217 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1218 let proj = tmp_proj();
1219 let mut sm = SessionManager::new(&proj);
1220 sm.create_session("t", None).unwrap();
1221 let sink = CollectingSink::new(false);
1222 let cancel = AtomicBool::new(false);
1223 let _ = loop_
1224 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1225 .await;
1226 assert_eq!(
1227 *counter.lock().unwrap(),
1228 1,
1229 "config error must not trigger retry"
1230 );
1231 }
1232
1233 #[tokio::test]
1234 async fn execute_with_retry_timeout_retried() {
1235 let mut reg = ToolRegistry::new();
1236 let (tool, counter) =
1237 CountingTool::new("timeout_cmd", "command timed out after 10s".to_string());
1238 reg.register(Box::new(tool));
1239 let mut cfg = AgentConfig::default();
1240 cfg.limits.max_retries = 3;
1241 cfg.max_iterations = 1;
1242 let responses = vec![LlmResponse {
1243 text: "try".into(),
1244 tool_calls: vec![tc("timeout_cmd", r#"{"x":"1"}"#)],
1245 ..Default::default()
1246 }];
1247 let llm = ScriptedLlm {
1248 responses: Mutex::new(responses),
1249 };
1250 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1251 let proj = tmp_proj();
1252 let mut sm = SessionManager::new(&proj);
1253 sm.create_session("t", None).unwrap();
1254 let sink = CollectingSink::new(false);
1255 let cancel = AtomicBool::new(false);
1256 let _ = loop_
1257 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1258 .await;
1259 assert!(*counter.lock().unwrap() >= 2, "timeout must trigger retry");
1260 }
1261
1262 #[tokio::test]
1263 async fn execute_with_retry_connection_retried() {
1264 let mut reg = ToolRegistry::new();
1265 let (tool, counter) = CountingTool::new("fetch_url", "connection reset".to_string());
1266 reg.register(Box::new(tool));
1267 let mut cfg = AgentConfig::default();
1268 cfg.limits.max_retries = 3;
1269 cfg.max_iterations = 1;
1270 let responses = vec![LlmResponse {
1271 text: "try".into(),
1272 tool_calls: vec![tc("fetch_url", r#"{"url":"http://x"}"#)],
1273 ..Default::default()
1274 }];
1275 let llm = ScriptedLlm {
1276 responses: Mutex::new(responses),
1277 };
1278 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1279 let proj = tmp_proj();
1280 let mut sm = SessionManager::new(&proj);
1281 sm.create_session("t", None).unwrap();
1282 let sink = CollectingSink::new(false);
1283 let cancel = AtomicBool::new(false);
1284 let _ = loop_
1285 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1286 .await;
1287 assert!(
1288 *counter.lock().unwrap() >= 2,
1289 "connection must trigger retry"
1290 );
1291 }
1292
1293 #[tokio::test]
1294 async fn execute_with_retry_respects_cancel() {
1295 let responses = vec![LlmResponse {
1296 text: "try".into(),
1297 tool_calls: vec![tc("timeout_cmd", "{\"x\":\"1\"}")],
1298 ..Default::default()
1299 }];
1300 let llm = ScriptedLlm {
1301 responses: Mutex::new(responses),
1302 };
1303 let mut reg = ToolRegistry::new();
1304 reg.register(Box::new(TimeoutTool));
1305 let mut cfg = AgentConfig::default();
1306 cfg.limits.max_retries = 10;
1307 cfg.max_iterations = 5;
1308 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1309 let proj = tmp_proj();
1310 let mut sm = SessionManager::new(&proj);
1311 sm.create_session("t", None).unwrap();
1312 let sink = CollectingSink::new(false);
1313 let cancel = AtomicBool::new(true); let res = loop_
1315 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1316 .await
1317 .unwrap();
1318 assert_eq!(res.status, RunStatus::Cancelled);
1321 }
1322
1323 #[tokio::test]
1324 async fn max_context_tokens_enforced_and_errors() {
1325 let llm = ScriptedLlm {
1326 responses: Mutex::new(vec![LlmResponse {
1327 text: "done".into(),
1328 ..Default::default()
1329 }]),
1330 };
1331 let reg = ToolRegistry::new();
1332 let mut cfg = AgentConfig::default();
1333 cfg.max_iterations = 5;
1334 cfg.context.max_context_tokens = 10;
1335 cfg.context.compact_trigger_tokens = 5;
1336 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1337
1338 let proj = tmp_proj();
1339 let mut sm = SessionManager::new(&proj);
1340 sm.create_session("t", None).unwrap();
1341 let sink = CollectingSink::new(false);
1342 let cancel = AtomicBool::new(false);
1343
1344 let msgs = vec![
1345 ChatMessage::system("you are a helpful assistant who provides detailed responses"),
1346 ChatMessage::user("do something"),
1347 ];
1348 let res = loop_.run(msgs, &mut sm, &sink, &cancel).await;
1349 assert!(
1350 res.is_err(),
1351 "context over max must error, not silently proceed"
1352 );
1353 let msg = format!("{}", res.err().unwrap());
1354 assert!(
1355 msg.contains("context too large") || msg.contains("exceeds limit"),
1356 "error must mention context size: {msg}"
1357 );
1358 }
1359
1360 #[tokio::test]
1363 async fn pipe_mode_retry_buffers_and_replays() {
1364 let llm = FailThenStreamLlm {
1368 remaining_failures: Mutex::new(1),
1369 };
1370 let reg = ToolRegistry::new();
1371 let mut cfg = AgentConfig::default();
1372 cfg.limits.max_retries = 3;
1373 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1374
1375 let proj = tmp_proj();
1376 let mut sm = SessionManager::new(&proj);
1377 sm.create_session("t", None).unwrap();
1378 let sink = CollectingSink::new(false);
1380 let cancel = AtomicBool::new(false);
1381
1382 let res = loop_
1383 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1384 .await
1385 .unwrap();
1386 assert_eq!(res.status, RunStatus::Completed);
1387 assert_eq!(res.final_text, "complete response");
1388
1389 let lines = sink.lines();
1390 assert!(
1392 lines
1393 .iter()
1394 .any(|l| l.contains(r#""type":"delta""#) && l.contains("streaming")),
1395 "delta event must be replayed to real sink"
1396 );
1397 assert!(
1398 lines
1399 .iter()
1400 .any(|l| l.contains(r#""type":"seg_content""#) && l.contains("complete response")),
1401 "seg_content event must be replayed to real sink"
1402 );
1403 }
1404
1405 #[tokio::test]
1406 async fn tty_mode_never_buffers_retries() {
1407 let llm = FailThenStreamLlm {
1409 remaining_failures: Mutex::new(1),
1410 };
1411 let reg = ToolRegistry::new();
1412 let mut cfg = AgentConfig::default();
1413 cfg.limits.max_retries = 3;
1414 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1415
1416 let proj = tmp_proj();
1417 let mut sm = SessionManager::new(&proj);
1418 sm.create_session("t", None).unwrap();
1419 let sink = CollectingSink::new(true);
1421 let cancel = AtomicBool::new(false);
1422
1423 let res = loop_
1424 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1425 .await
1426 .unwrap();
1427 assert_eq!(res.status, RunStatus::Completed);
1428
1429 let lines = sink.lines();
1434 assert!(
1436 lines.iter().any(|l| l == "streaming"),
1437 "TTY delta must emit raw text: {lines:?}"
1438 );
1439 }
1440
1441 #[tokio::test]
1442 async fn multiple_retries_only_last_success_replayed() {
1443 let llm = FailThenStreamLlm {
1446 remaining_failures: Mutex::new(2),
1447 };
1448 let reg = ToolRegistry::new();
1449 let mut cfg = AgentConfig::default();
1450 cfg.limits.max_retries = 4;
1451 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1452
1453 let proj = tmp_proj();
1454 let mut sm = SessionManager::new(&proj);
1455 sm.create_session("t", None).unwrap();
1456 let sink = CollectingSink::new(false);
1457 let cancel = AtomicBool::new(false);
1458
1459 let res = loop_
1460 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1461 .await
1462 .unwrap();
1463 assert_eq!(res.status, RunStatus::Completed);
1464
1465 let lines = sink.lines();
1466 let seg_count = lines
1470 .iter()
1471 .filter(|l| l.contains(r#""type":"seg_content""#) && l.contains("complete response"))
1472 .count();
1473 assert_eq!(
1474 seg_count, 1,
1475 "only one seg_content for the final thought; found {seg_count}. lines: {lines:?}"
1476 );
1477 }
1478
1479 #[tokio::test]
1480 async fn first_attempt_streams_directly_leak_is_allowed() {
1481 let llm = EmitThenFailLlm {
1487 call: Mutex::new(0),
1488 };
1489 let reg = ToolRegistry::new();
1490 let mut cfg = AgentConfig::default();
1491 cfg.limits.max_retries = 3;
1492 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1493
1494 let proj = tmp_proj();
1495 let mut sm = SessionManager::new(&proj);
1496 sm.create_session("t", None).unwrap();
1497 let sink = CollectingSink::new(false);
1498 let cancel = AtomicBool::new(false);
1499
1500 let res = loop_
1501 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1502 .await
1503 .unwrap();
1504 assert_eq!(res.status, RunStatus::Completed);
1505 assert_eq!(res.final_text, "final complete text");
1506
1507 let lines = sink.lines();
1508 assert!(
1510 lines.iter().any(|l| l.contains("partial text")),
1511 "first attempt leaked: {lines:?}"
1512 );
1513 assert!(
1515 lines.iter().any(|l| l.contains("final complete text")),
1516 "retry must replay: {lines:?}"
1517 );
1518 }
1521
1522 #[tokio::test]
1523 async fn buffered_retry_replays_all_event_types() {
1524 let llm = FailThenStreamLlm {
1527 remaining_failures: Mutex::new(1),
1528 };
1529 let reg = ToolRegistry::new();
1530 let mut cfg = AgentConfig::default();
1531 cfg.limits.max_retries = 3;
1532 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1533
1534 let proj = tmp_proj();
1535 let mut sm = SessionManager::new(&proj);
1536 sm.create_session("t", None).unwrap();
1537 let sink = CollectingSink::new(false);
1538 let cancel = AtomicBool::new(false);
1539
1540 let res = loop_
1541 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1542 .await
1543 .unwrap();
1544 assert_eq!(res.status, RunStatus::Completed);
1545
1546 let lines = sink.lines();
1547 assert!(
1549 lines
1550 .iter()
1551 .any(|l| l.contains(r#""type":"delta""#) && l.contains("streaming")),
1552 "delta missing: {lines:?}"
1553 );
1554 assert!(
1556 lines.iter().any(|l| l.contains(r#""type":"seg_content""#) && l.contains(r#""seg":"thinking""#)),
1557 "thinking seg missing: {lines:?}"
1558 );
1559 assert!(
1561 lines
1562 .iter()
1563 .any(|l| l.contains(r#""type":"seg_content""#) && l.contains(r#""seg":"thought""#)),
1564 "thought seg missing: {lines:?}"
1565 );
1566 assert!(
1569 lines.iter().any(|l| l.contains(r#""type":"done""#)),
1570 "done event missing: {lines:?}"
1571 );
1572 }
1573
1574 #[tokio::test]
1575 async fn buffered_retry_respects_cancel() {
1576 let llm = FailThenStreamLlm {
1579 remaining_failures: Mutex::new(0),
1580 };
1581 let reg = ToolRegistry::new();
1582 let mut cfg = AgentConfig::default();
1583 cfg.limits.max_retries = 3;
1584 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1585
1586 let proj = tmp_proj();
1587 let mut sm = SessionManager::new(&proj);
1588 sm.create_session("t", None).unwrap();
1589 let sink = CollectingSink::new(false);
1590 let cancel = AtomicBool::new(true);
1591
1592 let res = loop_
1593 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1594 .await
1595 .unwrap();
1596 assert_eq!(res.status, RunStatus::Cancelled);
1597 }
1598
1599 #[tokio::test]
1600 async fn cancel_during_retry_backoff_stops_cleanly() {
1601 use std::sync::Arc;
1602 let llm = FailThenStreamLlm {
1603 remaining_failures: Mutex::new(2),
1604 };
1605 let reg = ToolRegistry::new();
1606 let mut cfg = AgentConfig::default();
1607 cfg.limits.max_retries = 5;
1608 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1609
1610 let proj = tmp_proj();
1611 let mut sm = SessionManager::new(&proj);
1612 sm.create_session("t", None).unwrap();
1613 let sink = CollectingSink::new(false);
1614 let cancel = Arc::new(AtomicBool::new(false));
1615
1616 let cancel2 = Arc::clone(&cancel);
1617 std::thread::spawn(move || {
1618 std::thread::sleep(std::time::Duration::from_millis(500));
1620 cancel2.store(true, Ordering::SeqCst);
1621 });
1622
1623 let res = loop_
1624 .run(
1625 vec![ChatMessage::user("x")],
1626 &mut sm,
1627 &sink,
1628 cancel.as_ref(),
1629 )
1630 .await
1631 .unwrap();
1632 assert_eq!(
1633 res.status,
1634 RunStatus::Cancelled,
1635 "cancel during retry backoff must return Cancelled"
1636 );
1637 }
1638
1639 #[tokio::test]
1640 async fn exhausted_retries_buffers_discarded_no_events() {
1641 let llm = AlwaysFailLlm;
1644 let reg = ToolRegistry::new();
1645 let mut cfg = AgentConfig::default();
1646 cfg.limits.max_retries = 3;
1647 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1648
1649 let proj = tmp_proj();
1650 let mut sm = SessionManager::new(&proj);
1651 sm.create_session("t", None).unwrap();
1652 let sink = CollectingSink::new(false);
1653 let cancel = AtomicBool::new(false);
1654
1655 let res = loop_
1656 .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1657 .await
1658 .unwrap();
1659 assert!(matches!(res.status, RunStatus::Error(_)));
1660 let lines = sink.lines();
1662 assert!(
1663 lines.iter().any(|l| l.contains(r#""type":"error""#)),
1664 "error event must be emitted: {lines:?}"
1665 );
1666 let deltas = lines
1668 .iter()
1669 .filter(|l| l.contains(r#""type":"delta""#))
1670 .count();
1671 let segs = lines
1672 .iter()
1673 .filter(|l| l.contains(r#""type":"seg_content""#))
1674 .count();
1675 assert_eq!(deltas, 0, "no delta events from failed retries: {lines:?}");
1676 assert_eq!(
1677 segs, 0,
1678 "no seg_content events from failed retries: {lines:?}"
1679 );
1680 }
1681
1682 struct BlockingTool;
1684 #[async_trait::async_trait]
1685 impl Tool for BlockingTool {
1686 fn schema(&self) -> ToolSchema {
1687 ToolSchema::new("blocking_tool", "never returns", vec![])
1688 }
1689 async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
1690 futures::future::pending::<()>().await;
1691 Ok(String::new())
1692 }
1693 }
1694
1695 #[tokio::test]
1698 async fn cancel_interrupts_a_blocking_tool() {
1699 let llm = ScriptedLlm {
1700 responses: Mutex::new(vec![LlmResponse {
1701 text: "work".into(),
1702 tool_calls: vec![tc("blocking_tool", "{}")],
1703 ..Default::default()
1704 }]),
1705 };
1706 let mut reg = ToolRegistry::new();
1707 reg.register(Box::new(BlockingTool));
1708 let cfg = AgentConfig::default();
1709 let loop_ = ReactLoop::new(&llm, ®, &cfg, vec![]);
1710 let proj = tmp_proj();
1711 let mut sm = SessionManager::new(&proj);
1712 sm.create_session("t", None).unwrap();
1713 let sink = CollectingSink::new(false);
1714 let cancel = Arc::new(AtomicBool::new(false));
1715 let c2 = cancel.clone();
1716 std::thread::spawn(move || {
1717 std::thread::sleep(std::time::Duration::from_millis(200));
1718 c2.store(true, Ordering::SeqCst);
1719 });
1720 let start = std::time::Instant::now();
1721 let res = loop_
1722 .run(
1723 vec![ChatMessage::user("x")],
1724 &mut sm,
1725 &sink,
1726 cancel.as_ref(),
1727 )
1728 .await
1729 .unwrap();
1730 assert!(
1731 matches!(res.status, RunStatus::Cancelled),
1732 "expected Cancelled, got {:?}",
1733 res.status
1734 );
1735 assert!(
1736 start.elapsed() < std::time::Duration::from_secs(3),
1737 "cancel should be prompt, took {:?}",
1738 start.elapsed()
1739 );
1740 }
1741}
1742
1743async fn cancel_checker(cancel: &AtomicBool, notify: &Notify) {
1746 use std::time::Duration;
1747 loop {
1748 tokio::select! {
1749 _ = notify.notified() => {},
1750 _ = tokio::time::sleep(Duration::from_secs(1)) => {},
1751 }
1752 if cancel.load(Ordering::SeqCst) {
1753 return;
1754 }
1755 }
1756}
1757
1758fn brief(e: &crate::error::AacodeError) -> String {
1760 let s = e.to_string();
1761 let first = s.lines().next().unwrap_or(&s);
1762 let mut out: String = first.chars().take(60).collect();
1763 if first.chars().count() > 60 {
1764 out.push('…');
1765 }
1766 out
1767}