Skip to main content

aacode_rs/agent/
react_loop.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! The ReAct loop — iterate model calls + tool execution until completion.
5//!
6//! Ported from Python `core/react_loop.py` (core control flow). Completion is
7//! detected exactly as in Python: the model returns no tool_calls → the final
8//! text is the summary and the loop ends.
9
10use 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/// Outcome of a run.
25#[derive(Debug, Clone, PartialEq)]
26pub enum RunStatus {
27    Completed,
28    MaxIterations,
29    Cancelled,
30    Error(String),
31}
32
33impl RunStatus {
34    /// Stable wire name for the status (used by the `done` event and the
35    /// terminal JSON returned by the FFI `wait`).
36    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    /// Serialize the terminal outcome as JSON (shared by `done` enrichment and
54    /// the FFI `wait` return value).
55    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
64/// The ReAct loop driver. Borrows the collaborators for the duration of a run.
65pub struct ReactLoop<'a> {
66    pub llm: &'a dyn LlmClient,
67    pub tools: &'a ToolRegistry,
68    pub config: &'a AgentConfig,
69    /// Preformatted native tools for the active gateway.
70    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    /// Run the loop. `messages` starts with the system prompt + history + task.
89    /// New messages are persisted to `session` incrementally.
90    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        // Frozen compaction state: keeps the request prefix byte-stable
100        // across iterations so the provider KV cache keeps hitting.
101        let mut compact_cache: Option<CompactCache> = None;
102        let session_id = session.current_session_id.clone().unwrap_or_default();
103        // Track the last response text so MaxIterations can return it.
104        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            // Build compact view for the model call (does not mutate messages).
117            let (mut view, compacted, tokens) =
118                build_compact_view_cached(&messages, &self.config.context, &mut compact_cache);
119            // Enforce hard context limit: if even the compacted view exceeds
120            // max_context_tokens the request would be rejected or silently
121            // truncated by the API — fail fast instead.
122            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            // Only re-sanitize after compaction — uncompacted views were
131            // already sanitized in MainAgent (history repair) and the ReAct
132            // loop never creates broken tool_calls/tool pairings on its own.
133            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            // Completion: no tool calls.
164            if resp.tool_calls.is_empty() {
165                // If the response was truncated (max_tokens / length), the
166                // model may have intended to output tool calls that got cut
167                // off. Inject a continuation so the model can keep going.
168                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                // Flush before the terminal event so a host re-reading the
194                // session right after `done` sees the final assistant message.
195                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            // Stale-loop detection warning (fetch_url stale domains).
205            // Persisted so cross-execute() prefix stays stable.
206            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            // Append the assistant message carrying tool_calls.
213            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            // Execute tool calls.
221            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; // reference from outer scope
226
227                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                // Single tool: sequential path
278                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            // Context growth check (informational; compact view is built each iter).
308            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    /// Call the model with retry on transient (network/5xx/timeout) errors.
326    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            // On retry in pipe mode (mobile FFI), buffer output in a
340            // CollectingSink so partial content from a failed stream never
341            // leaks to the client (avoids "回退" — segment content shrinking
342            // mid-stream). Only replay when the stream succeeds.
343            // First attempt still streams directly for real-time UX.
344            // TTY mode (CLI) never buffers — it has no segment tracking
345            // and stale partial text is harmless.
346            let use_buffered = attempt > 0 && !emitter.is_tty();
347            // Compute result first — LlmResponse is self-contained (all
348            // owned), so the CollectingSink borrow ends before we move it.
349            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                    // Surface the retry to the UI (tool_progress renders as a
381                    // live status line) — a silent backoff looks like a hang,
382                    // especially on flaky mobile networks.
383                    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                    // exponential backoff: 1s, 2s, 4s, ... with ±25% jitter
395                    // so concurrent retries don't form a thundering herd.
396                    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); // 250ms * delay
403                        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    /// Execute a tool, retrying transient failures per config.limits.max_retries.
418    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            // Race the tool against cancellation so a long/blocking tool (e.g.
432            // a 60s `run_shell`) does not delay a cancel. The tool future is
433            // dropped on cancel; a `spawn_blocking` command may finish in the
434            // background but the agent stops promptly (and releases the session
435            // guard). The local Notify is never notified → 1s polling fallback.
436            let notify = Notify::new();
437            let obs = tokio::select! {
438                obs = self.tools.execute(name, args.clone(), cancel) => obs,
439                _ = cancel_checker(cancel, &notify) => 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
456/// Classify an LLM/API error into a friendly, actionable message.
457fn 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/// Tracks fetch_url results per domain to detect stale loops (mirrors Python).
474#[derive(Default)]
475struct StaleTracker {
476    by_domain: HashMap<String, Vec<bool>>, // has_content flags
477    warned: std::collections::HashSet<String>,
478}
479
480impl StaleTracker {
481    fn detect(&mut self, tool_calls: &[ToolCall]) -> Option<String> {
482        // Only meaningful when the model is issuing fetch_url calls.
483        if !tool_calls.iter().any(|tc| tc.name == "fetch_url") {
484            return None;
485        }
486        // Find a domain with 3 recent no-content fetches that we haven't warned about.
487        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        // strip tags, check readable length
511        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    // naive scheme://host/... parse
527    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    // A scripted LLM that returns a queue of responses.
546    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    // Scripted LLM that also records every request's messages.
572    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, &reg, &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, &reg, &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        // observation emitted
694        assert!(sink.lines().iter().any(|l| l.contains("echoed: hey")));
695        // session persisted assistant(tool_calls) + tool + final assistant
696        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, &reg, &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        // The turn is persisted as plain messages only: assistant(tool_calls)
733        // carries reasoning + content (~ thinking/thought/action), the tool
734        // message carries the observation — no duplicated render segments.
735        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    /// The exact messages handed to the model must contain each tool call and
765    /// each tool result exactly once (no duplication), adjacent, with no missing
766    /// result and no stray duplicated system/user rows.
767    #[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, &reg, &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        // No duplicated user/system rows slipped in.
824        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        // Persisted history maps 1:1 (no duplicate rows on disk either).
828        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, &reg, &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        // Always returns a tool call → never completes.
854        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, &reg, &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    // An LLM that fails N times with a retryable error, then succeeds.
899    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, &reg, &cfg, vec![]);
935        let sink = CollectingSink::new(false);
936        let cancel = AtomicBool::new(false);
937        // Directly exercise chat_with_retry via a completing run.
938        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        // Truncated response with no tool calls → injects "continue" →
952        // needs another LLM response. We provide a chain so the loop can
953        // exhaust iterations without crashing on empty ScriptedLlm.
954        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, &reg, &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        // Truncated response with no tool calls should NOT complete — it
980        // should loop until max_iterations and then return MaxIterations.
981        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, &reg, &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    // ── LLMs for retry / buffering tests ──────────────────────────────
1022
1023    /// Fails N times with a retryable error, then succeeds with stream events.
1024    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    /// Emits events on attempt 0 then fails, succeeds on attempt 1 with
1055    /// different content. Simulates a mid-stream disconnect followed by
1056    /// a successful retry.
1057    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                // Simulate partial stream output before a mid-stream error.
1073                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            // Retry: completely different (correct) content.
1079            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    /// Fails every call (never succeeds). Used for exhausted-retry tests.
1093    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    /// A tool that always returns "timeout" observation — triggers retry.
1111    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    /// Counts how many times its tool was invoked.
1127    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, &reg, &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, &reg, &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, &reg, &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, &reg, &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, &reg, &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); // pre-set cancel
1314        let res = loop_
1315            .run(vec![ChatMessage::user("x")], &mut sm, &sink, &cancel)
1316            .await
1317            .unwrap();
1318        // Must not retry forever — cancel check in first iteration returns
1319        // Cancelled before tool execution even starts.
1320        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, &reg, &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    // ── retry buffering tests ────────────────────────────────────────
1361
1362    #[tokio::test]
1363    async fn pipe_mode_retry_buffers_and_replays() {
1364        // FlakyLlm fails once then returns Ok (no events emitted).
1365        // With buffering, the emitter must only see output from the
1366        // retry attempt — nothing leaked from the failed first attempt.
1367        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, &reg, &cfg, vec![]);
1374
1375        let proj = tmp_proj();
1376        let mut sm = SessionManager::new(&proj);
1377        sm.create_session("t", None).unwrap();
1378        // Pipe mode (non-TTY) — buffering should engage on retry.
1379        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        // The retry emitted delta + seg events. Verify they are replayed.
1391        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        // In TTY mode, attempts > 0 still use the real emitter directly.
1408        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, &reg, &cfg, vec![]);
1415
1416        let proj = tmp_proj();
1417        let mut sm = SessionManager::new(&proj);
1418        sm.create_session("t", None).unwrap();
1419        // TTY mode — buffering should NOT engage.
1420        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        // The delta("thought", ...) goes through token_line → emit_line in
1430        // TTY mode. The seg("thought", ...) default impl emits JSONL
1431        // regardless of TTY flag on CollectingSink. So we should find
1432        // both raw text and JSONL in the output.
1433        let lines = sink.lines();
1434        // In TTY mode, delta emits raw text via token_line.
1435        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        // Fail twice, succeed on third. Each failure must be discarded;
1444        // only the successful attempt's events reach the real emitter.
1445        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, &reg, &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        // Each retry's buffer was discarded on failure. Only the final
1467        // success should appear. The exact content comes from attempt 2
1468        // (index 2, the third call).
1469        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        // Attempt 0 emits events DIRECTLY to real emitter then fails.
1482        // Attempt 1 (buffered) retries. The real emitter will contain
1483        // BOTH the leaked attempt-0 events AND the replayed attempt-1
1484        // events. This is by design: real-time streaming on first
1485        // attempt trades perfect isolation for live UI feedback.
1486        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, &reg, &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        // Attempt 0 leaked "partial text" directly to the real emitter.
1509        assert!(
1510            lines.iter().any(|l| l.contains("partial text")),
1511            "first attempt leaked: {lines:?}"
1512        );
1513        // Attempt 1 replayed "final complete text" to the real emitter.
1514        assert!(
1515            lines.iter().any(|l| l.contains("final complete text")),
1516            "retry must replay: {lines:?}"
1517        );
1518        // Both sets appear, but retry content is the authoritative final
1519        // seg_content that the iOS side should use.
1520    }
1521
1522    #[tokio::test]
1523    async fn buffered_retry_replays_all_event_types() {
1524        // Verify that the buffered CollectingSink captures and replays
1525        // all event types: delta, seg_content (thinking + thought).
1526        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, &reg, &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        // Delta event replayed.
1548        assert!(
1549            lines
1550                .iter()
1551                .any(|l| l.contains(r#""type":"delta""#) && l.contains("streaming")),
1552            "delta missing: {lines:?}"
1553        );
1554        // Thinking seg_content replayed.
1555        assert!(
1556            lines.iter().any(|l| l.contains(r#""type":"seg_content""#) && l.contains(r#""seg":"thinking""#)),
1557            "thinking seg missing: {lines:?}"
1558        );
1559        // Thought seg_content replayed.
1560        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        // done event from the react_loop (emitted after chat_with_retry
1567        // returns, not from the LLM).
1568        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        // Pre-set cancel: the cancel check at the top of each iteration
1577        // (in ReactLoop::run) catches it before chat_with_retry is called.
1578        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, &reg, &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, &reg, &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            // Wait for the first failure + backoff sleep to start.
1619            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        // When all retries fail, no buffered content should reach the
1642        // real emitter (each buffer was discarded on failure).
1643        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, &reg, &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        // The react_loop emits an error event on the real emitter.
1661        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        // But there should be no delta/seg_content from retries.
1667        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    /// A tool that never returns (like a long blocking `run_shell`).
1683    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    /// Cancel must interrupt a blocking tool promptly (the loop must not wait
1696    /// for it), which also lets the session guard be released.
1697    #[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, &reg, &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
1743/// Returns a future that completes when the cancel flag is set.
1744/// Uses Notify for immediate wakeup, with 1s polling fallback.
1745async 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
1758/// Short one-line error description for UI status lines.
1759fn 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}