Skip to main content

vtcode_core/core/agent/
completion.rs

1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4/// Successful tracker mutations establish adoption within the caller's current
5/// request history. Listing, rejected calls, and unrelated tool results do not.
6pub fn tracker_was_adopted(history: &[crate::llm::provider::Message]) -> bool {
7    let mut pending_tracker_calls = std::collections::HashMap::new();
8    for message in history {
9        if message.role == MessageRole::Assistant {
10            // Call IDs can be reused between batches; never match an unanswered
11            // tracker request to a later batch's result.
12            pending_tracker_calls.clear();
13            for call in message.tool_calls.iter().flatten() {
14                let Some(function) = call
15                    .function
16                    .as_ref()
17                    .filter(|function| function.name == crate::config::constants::tools::TASK_TRACKER)
18                else {
19                    continue;
20                };
21                let Ok(args) = serde_json::from_str::<serde_json::Value>(&function.arguments) else {
22                    continue;
23                };
24                if matches!(args.get("action").and_then(serde_json::Value::as_str), Some("create" | "update" | "add")) {
25                    pending_tracker_calls.insert(call.id.as_str(), args);
26                }
27            }
28        } else if message.role == MessageRole::Tool
29            && let Some(args) = message.tool_call_id.as_deref().and_then(|id| pending_tracker_calls.remove(id))
30            && let Ok(output) = serde_json::from_str::<serde_json::Value>(message.content.as_text().as_ref())
31            && tracker_adoption_succeeded(crate::config::constants::tools::TASK_TRACKER, &args, &output)
32        {
33            return true;
34        }
35    }
36    false
37}
38
39/// Observe adoption before tool output or conversation history is compacted.
40pub fn tracker_adoption_succeeded(tool_name: &str, args: &serde_json::Value, output: &serde_json::Value) -> bool {
41    use serde_json::Value;
42    tool_name == crate::config::constants::tools::TASK_TRACKER
43        && output.get("error").is_none()
44        && output.get("success").and_then(Value::as_bool) != Some(false)
45        && output.get("blocked").and_then(Value::as_bool) != Some(true)
46        && output.get("not_executed").and_then(Value::as_bool) != Some(true)
47        && matches!(
48            (args.get("action").and_then(Value::as_str), output.get("status").and_then(Value::as_str)),
49            (Some("create"), Some("created" | "replaced" | "unchanged"))
50                | (Some("update"), Some("updated" | "unchanged"))
51                | (Some("add"), Some("added"))
52        )
53}
54
55/// True when assistant text is a genuine safety/permission handoff that must
56/// not be auto-continued, even if `task_tracker` still has incomplete steps.
57///
58/// Shared by the binary outer-loop Completed queue and AgentRunner status
59/// continuation so both surfaces use the same vocabulary. Safety/policy
60/// denials are evaluated first so a recap that mentions both a budget and a
61/// policy denial still counts as a handoff. Pure budget/recovery recaps are
62/// not handoffs (outer/in-turn recoverable classifiers treat those as continue).
63pub fn tracker_final_text_is_safety_handoff(text: &str) -> bool {
64    let lower = text.to_ascii_lowercase();
65    tracker_final_text_is_policy_handoff_lowercase(&lower)
66        || lower.contains("verification is still pending")
67        || lower.contains("unverified assistant responses")
68        || lower.contains("anti-blind")
69        || lower.contains("verification gate")
70}
71
72/// Permission, policy, safety, and credential handoffs remain terminal even
73/// when a pending-verification block can recover with fresh execution budget.
74/// Verification wording is handled separately by the turn's recovery gate.
75pub fn tracker_final_text_is_policy_handoff(text: &str) -> bool {
76    let lower = text.to_ascii_lowercase();
77    tracker_final_text_is_policy_handoff_lowercase(&lower)
78}
79
80fn tracker_final_text_is_policy_handoff_lowercase(lower: &str) -> bool {
81    if lower.trim().is_empty() {
82        return false;
83    }
84    // True handoffs win even when the text also mentions a budget.
85    if lower.contains("permission denied")
86        || lower.contains("access denied")
87        || lower.contains("safety fuse")
88        || lower.contains("tool-call safety fuse")
89        || lower.contains("policy block")
90        || lower.contains("blocked by policy")
91        || lower.contains("requires manual intervention")
92        || lower.contains("missing credentials")
93        || lower.contains("credentials are missing")
94        || lower.contains("denied by policy")
95        || lower.contains("denied by workspace tool policy")
96        || lower.contains("denied by tool policy")
97        || lower.contains("execution denied by policy")
98        || lower.contains("blocked by tool policy")
99    {
100        return true;
101    }
102    // Pure budget/recovery recaps ("blocked by turn budget", "tool loop
103    // budget exhausted", …) are not user handoffs.
104    false
105}
106
107/// Shared recoverable status-recap vocabulary for tracker continuation.
108///
109/// Used by the binary in-turn override and outer blocked-reason classifier
110/// tests so session/production budget phrases cannot drift between surfaces.
111/// Lowercase input expected.
112pub fn recoverable_status_recap_phrasing(lower: &str) -> bool {
113    lower.contains("turn budget")
114        || lower.contains("preview budget")
115        || lower.contains("tool preview budget")
116        || lower.contains("wall clock")
117        || lower.contains("safety cap")
118        || lower.contains("recovery fallback")
119        || lower.contains("recovery could not confirm")
120        || lower.contains("recovery exhausted")
121        || lower.contains("recovery was exhausted")
122        || lower.contains("tool budget")
123        || lower.contains("tool loop")
124        || lower.contains("tool-call budget")
125        || lower.contains("tool follow-up")
126        || lower.contains("read cap")
127        || lower.contains("work budget")
128        || lower.contains("max tool")
129        || lower.contains("per-turn tool")
130        || lower.contains("budget exhausted")
131        || lower.contains("budget ran out")
132}
133
134/// True when final assistant text asks the user for a decision/confirmation.
135///
136/// Shared by outer tracker auto-queue and in-turn continuation so Completed
137/// turns that end with a genuine question are not auto-continued past the ask.
138pub fn tracker_final_text_requires_user_input(text: &str) -> bool {
139    let trimmed = text.trim();
140    if trimmed.is_empty() {
141        return false;
142    }
143    if trimmed.ends_with('?') {
144        return true;
145    }
146    let lower = trimmed.to_ascii_lowercase();
147    const STRONG: &[&str] = &[
148        "please provide",
149        "please confirm",
150        "please approve",
151        "need your approval",
152        "need your permission",
153        "need your decision",
154        "need you to choose",
155        "need you to confirm",
156        "waiting for your",
157        "awaiting your",
158        "your choice",
159        "your decision",
160        "need approval",
161        "requires approval",
162        "approval is required",
163        "need permission",
164        "requires permission",
165        "permission is required",
166        "grant permission",
167        "authorize this",
168        "need a decision",
169        "need clarification",
170        "waiting for input",
171        "awaiting input",
172        "waiting on you",
173        "how should i proceed",
174        "what should i do",
175        "what would you like",
176        "how would you like",
177        "do you want me to",
178    ];
179    const CLAUSE_START: &[&str] = &["could you ", "can you ", "shall i ", "should i "];
180    if STRONG.iter().any(|p| lower.contains(p)) {
181        return true;
182    }
183    CLAUSE_START
184        .iter()
185        .any(|p| lower.trim_start().starts_with(p) || lower.contains(&format!("\n{p}")))
186}
187
188/// Checks if the agent's response is a candidate for completion handling.
189pub fn check_completion_candidate(response_text: &str) -> bool {
190    // High-confidence terminal markers that strongly indicate intent to stop.
191    const COMPLETION_SENTENCES: &[&str] = &[
192        "the task is complete",
193        "task is complete",
194        "task has been completed",
195        "i have successfully completed the task",
196        "work is finished",
197        "operation successful",
198        "i am done",
199        "no more actions needed",
200        "successfully accomplished",
201        "task is now complete",
202        "everything is finished",
203        "i've finished the task",
204        "all requested changes have been applied",
205        "i have finished all the work",
206    ];
207
208    // Lower-confidence markers that need to be at the core of the message.
209    const SOFT_INDICATORS: &[&str] = &[
210        "task completed",
211        "task done",
212        "all done",
213        "finished.",
214        "complete.",
215        "done.",
216    ];
217
218    const UNRESOLVED_PHRASES: &[&str] = &[
219        "still need to",
220        "remaining step",
221        "remaining work",
222        "verification pending",
223        "verification still pending",
224        "tests not run",
225        "haven't run",
226        "have not run",
227        "blocked on",
228        "open questions remain",
229        "question remains",
230        "todo:",
231        "not complete yet",
232        "once verification",
233        "after verification",
234    ];
235
236    let response_lower = response_text.to_lowercase();
237
238    if UNRESOLVED_PHRASES.iter().any(|phrase| response_lower.contains(phrase))
239        || structured_contract_has_unresolved_sections(response_text)
240    {
241        return false;
242    }
243
244    // Strategy 1: Explicit terminal sentences
245    if COMPLETION_SENTENCES.iter().any(|&s| response_lower.contains(s)) {
246        return true;
247    }
248
249    // Strategy 2: Soft indicators that appear at the very end or are the entire message
250    let trimmed = response_lower.trim();
251    for &indicator in SOFT_INDICATORS {
252        if trimmed.ends_with(indicator) || trimmed == indicator {
253            // Heuristic: Ensure it's not "I will soon have the task completed"
254            // Check if preceded by future-tense markers within the same sentence
255            let sentences: Vec<_> = trimmed.split(['.', '!', '?']).collect();
256            if let Some(last_sentence) = sentences.last() {
257                let ls = last_sentence.trim();
258                if ls.contains(indicator)
259                    && !ls.contains("will")
260                    && !ls.contains("going to")
261                    && !ls.contains("about to")
262                    && !ls.contains("once")
263                    && !ls.contains("after")
264                {
265                    return true;
266                }
267            }
268        }
269    }
270
271    // Strategy 3: Structured subagent markdown contract output.
272    // When the model produces the canonical "## Summary / ## Facts / ..." contract, it has
273    // finished its task even without an explicit done phrase.  Detect this by checking that
274    // the response opens with a "## Summary" heading (after stripping leading whitespace) and
275    // also contains a "## Facts" section.  Headers are matched line-by-line after trimming so
276    // CRLF, extra spaces, and capitalisation variations are handled uniformly.
277    {
278        let mut has_summary_header = false;
279        let mut has_facts_header = false;
280        for line in response_text.lines() {
281            let line_lower = line.trim().to_lowercase();
282            if line_lower == "## summary" || line_lower == "# summary" {
283                has_summary_header = true;
284            }
285            if line_lower == "## facts" || line_lower == "# facts" {
286                has_facts_header = true;
287            }
288            if has_summary_header && has_facts_header {
289                return true;
290            }
291        }
292    }
293
294    false
295}
296
297fn structured_contract_has_unresolved_sections(response_text: &str) -> bool {
298    let mut in_open_questions = false;
299    let mut in_verification = false;
300
301    for line in response_text.lines() {
302        let line_lower = line.trim().to_lowercase();
303        if line_lower.starts_with('#') {
304            in_open_questions = line_lower == "## open questions" || line_lower == "# open questions";
305            in_verification = line_lower == "## verification" || line_lower == "# verification";
306            continue;
307        }
308
309        if line_lower.is_empty() {
310            continue;
311        }
312
313        if in_open_questions && !section_entry_is_none(&line_lower) {
314            return true;
315        }
316
317        if in_verification && section_entry_is_unresolved(&line_lower) {
318            return true;
319        }
320    }
321
322    false
323}
324
325fn section_entry_is_none(line: &str) -> bool {
326    let normalized = normalized_section_entry(line).trim_end_matches('.');
327    matches!(normalized, "none" | "n/a")
328}
329
330fn section_entry_is_unresolved(line: &str) -> bool {
331    let normalized = normalized_section_entry(line);
332    normalized.contains("pending")
333        || normalized.contains("not run")
334        || normalized.contains("failed")
335        || normalized.contains("blocked")
336}
337
338fn normalized_section_entry(line: &str) -> &str {
339    line.trim_start_matches(['-', '*']).trim()
340}
341
342/// Check for repetitive text in assistant responses to catch non-tool-calling loops.
343/// Returns true if a loop is detected.
344pub fn check_for_response_loop(response_text: &str, session_state: &mut AgentSessionState) -> bool {
345    if response_text.len() < 10 {
346        return false;
347    }
348
349    // Compare word sequences, ignoring whitespace differences.
350    let repeated = session_state
351        .messages
352        .iter()
353        .rev()
354        .filter(|m| m.role == MessageRole::Assistant)
355        .skip(1)
356        .take(2)
357        .any(|m| {
358            let previous_text = m.content.as_text();
359            previous_text.split_whitespace().eq(response_text.split_whitespace())
360        });
361
362    if repeated {
363        let warning = "Repetitive assistant response detected. Breaking potential loop.".to_string();
364        session_state.push_warning(warning);
365        session_state.consecutive_idle_turns = session_state.consecutive_idle_turns.saturating_add(1);
366        return true;
367    }
368
369    false
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::llm::provider::{ContentPart, Message};
376
377    #[test]
378    fn handoff_classification_preserves_case_and_policy_precedence() {
379        for text in [
380            "Permission denied after turn budget exhaustion.",
381            "Access denied.",
382            "Safety fuse reached.",
383            "Tool-call safety fuse stopped verification.",
384            "Policy block remains.",
385            "Blocked by policy.",
386            "Requires manual intervention.",
387            "Missing credentials.",
388            "Credentials are missing.",
389            "Denied by policy.",
390            "Denied by workspace tool policy.",
391            "Denied by tool policy.",
392            "Execution denied by policy.",
393            "Blocked by tool policy.",
394        ] {
395            for variant in [text.to_string(), text.to_ascii_uppercase()] {
396                assert!(tracker_final_text_is_policy_handoff(&variant), "policy: {variant}");
397                assert!(tracker_final_text_is_safety_handoff(&variant), "safety: {variant}");
398            }
399        }
400        for text in [
401            "Verification is still pending after turn budget exhaustion.",
402            "Unverified assistant responses remain.",
403            "Anti-blind checkpoint reached.",
404            "Verification gate remains.",
405        ] {
406            let variant = text.to_ascii_uppercase();
407            assert!(!tracker_final_text_is_policy_handoff(&variant), "policy: {variant}");
408            assert!(tracker_final_text_is_safety_handoff(&variant), "safety: {variant}");
409        }
410        for text in [
411            "",
412            " \t\n\u{2003}",
413            "TURN BUDGET EXHAUSTED.",
414            "REVIEWED TOOL POLICY.",
415            "VERIFICATION PASSED.",
416        ] {
417            assert!(!tracker_final_text_is_policy_handoff(text), "policy: {text:?}");
418            assert!(!tracker_final_text_is_safety_handoff(text), "safety: {text:?}");
419        }
420    }
421
422    #[test]
423    fn policy_handoffs_are_distinct_from_pending_verification_recaps() {
424        for text in [
425            "Verification is still pending after the tool-call budget was exhausted.",
426            "Repeated unverified assistant responses reached the anti-blind verification gate.",
427        ] {
428            assert!(tracker_final_text_is_safety_handoff(text));
429            assert!(!tracker_final_text_is_policy_handoff(text));
430        }
431        for text in [
432            "Verification is still pending; permission denied.",
433            "Tool budget exhausted; blocked by tool policy.",
434            "Missing credentials for the verifier.",
435            "Tool-call safety fuse stopped verification.",
436        ] {
437            assert!(tracker_final_text_is_policy_handoff(text));
438            assert!(tracker_final_text_is_safety_handoff(text));
439        }
440    }
441
442    #[test]
443    fn tracker_final_text_is_safety_handoff_vocabulary() {
444        assert!(tracker_final_text_is_safety_handoff(
445            "Permission denied for exec_command. Next step: retry after access is granted."
446        ));
447        assert!(tracker_final_text_is_safety_handoff(
448            "I hit the tool-call safety fuse mid-verification; policy block."
449        ));
450        assert!(tracker_final_text_is_safety_handoff(
451            "Blocked action: exec_command is denied by workspace tool policy."
452        ));
453        assert!(tracker_final_text_is_safety_handoff(
454            "Turn blocked after repeated unverified assistant responses; verification is still pending."
455        ));
456        assert!(tracker_final_text_is_safety_handoff(
457            "Anti-blind checkpoint remains; verification is still pending."
458        ));
459        assert!(!tracker_final_text_is_safety_handoff(
460            "## Status\nBlocked by turn budget. Next step: read design/diff.rs."
461        ));
462        assert!(!tracker_final_text_is_safety_handoff(
463            "## Status\nBlocked by turn tool budget. Next step: patch the struct."
464        ));
465        assert!(!tracker_final_text_is_safety_handoff(
466            "Tool loop budget exhausted; continuing next turn with remaining tracker steps."
467        ));
468        assert!(!tracker_final_text_is_safety_handoff(
469            "Fix 1 partially applied, needs verification; next step is cargo check."
470        ));
471        // Bare tool-policy mention is not a handoff; explicit denial still is.
472        assert!(!tracker_final_text_is_safety_handoff("Reviewed tool policy docs; next step is the tracker patch."));
473        assert!(tracker_final_text_is_safety_handoff(
474            "Blocked action: exec_command is denied by workspace tool policy."
475        ));
476        assert!(!tracker_final_text_is_safety_handoff("Implemented patch apply; verification passed."));
477        assert!(!tracker_final_text_is_safety_handoff(""));
478        // Policy denial wins even when a budget is also mentioned.
479        assert!(tracker_final_text_is_safety_handoff("Tool budget exhausted; denied by policy for exec_command."));
480        assert!(tracker_final_text_is_safety_handoff("Turn budget hit, then permission denied for the write."));
481    }
482
483    #[test]
484    fn recoverable_status_recap_phrasing_covers_session_budget_recaps() {
485        for phrase in [
486            "blocked by the turn's preview budget",
487            "tool budget ran out",
488            "hit the per-file read cap",
489            "preview budget exhausted",
490            "tool loop budget exhausted",
491            "recovery fallback",
492            "reached the safety cap",
493            "work budget exhausted",
494        ] {
495            assert!(
496                recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
497                "must treat as recoverable: {phrase}"
498            );
499        }
500        for phrase in [
501            "permission denied for exec_command",
502            "verification is still pending",
503            "needs a user decision",
504        ] {
505            assert!(
506                !recoverable_status_recap_phrasing(&phrase.to_ascii_lowercase()),
507                "must not treat as recoverable budget phrasing: {phrase}"
508            );
509        }
510    }
511
512    #[test]
513    fn tracker_final_text_requires_user_input_vocabulary() {
514        assert!(tracker_final_text_requires_user_input("Step 1 done. Which branch should I use for step 2?"));
515        assert!(tracker_final_text_requires_user_input(
516            "## Status\nPlease confirm whether to use the stable schema."
517        ));
518        assert!(tracker_final_text_requires_user_input("Can you confirm the migration path before I edit."));
519        assert!(!tracker_final_text_requires_user_input(
520            "## Status\nNext we need your workspace path in CONFIG; patching the struct now."
521        ));
522        assert!(!tracker_final_text_requires_user_input("Blocked by turn budget. Next step: read helpers.rs."));
523        assert!(!tracker_final_text_requires_user_input(""));
524    }
525
526    #[test]
527    fn test_completion_candidates() {
528        assert!(check_completion_candidate("The task is complete"));
529        assert!(check_completion_candidate("Revision 1: task is complete."));
530        assert!(check_completion_candidate("I have successfully completed the task."));
531        assert!(check_completion_candidate("Task done"));
532        assert!(check_completion_candidate("All done"));
533
534        // Negative cases
535        assert!(!check_completion_candidate("I will have the task done soon"));
536        assert!(!check_completion_candidate("Is the task done?"));
537        assert!(!check_completion_candidate("random text"));
538        assert!(!check_completion_candidate("The task is complete once verification finishes."));
539        assert!(!check_completion_candidate("All done. Verification pending."));
540        assert!(!check_completion_candidate("All done, but open questions remain."));
541    }
542
543    #[test]
544    fn subagent_markdown_contract_detected_as_complete() {
545        let contract = "## Summary\n- Background subprocess launched; PID 86065.\n\n## Facts\n- Script started at 2026-04-25T08:39:10Z.\n\n## Touched Files\n- None\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None";
546        assert!(check_completion_candidate(contract));
547    }
548
549    #[test]
550    fn subagent_markdown_contract_with_crlf_detected_as_complete() {
551        let contract = "## Summary\r\n- Done.\r\n\r\n## Facts\r\n- Fact 1.\r\n";
552        assert!(check_completion_candidate(contract));
553    }
554
555    #[test]
556    fn subagent_markdown_contract_with_leading_whitespace_detected() {
557        let contract = "\n\n## Summary\n- item\n\n## Facts\n- fact\n";
558        assert!(check_completion_candidate(contract));
559    }
560
561    #[test]
562    fn document_with_only_summary_header_not_detected() {
563        let doc = "## Summary\n- This is a doc without a Facts section.\n";
564        assert!(!check_completion_candidate(doc));
565    }
566
567    #[test]
568    fn document_with_only_facts_header_not_detected() {
569        let doc = "## Facts\n- Fact without summary.\n";
570        assert!(!check_completion_candidate(doc));
571    }
572
573    #[test]
574    fn structured_contract_with_open_questions_is_not_complete() {
575        let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- Need to rerun the end-to-end flow.";
576        assert!(!check_completion_candidate(doc));
577    }
578
579    #[test]
580    fn structured_contract_with_unresolved_verification_is_not_complete() {
581        let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Verification pending.\n\n## Open Questions\n- None";
582        assert!(!check_completion_candidate(doc));
583    }
584
585    #[test]
586    fn structured_contract_with_none_punctuation_is_complete() {
587        let doc = "## Summary\n- Work applied.\n\n## Facts\n- Fact.\n\n## Verification\n- Process confirmed.\n\n## Open Questions\n- None.";
588        assert!(check_completion_candidate(doc));
589    }
590
591    #[test]
592    fn response_loop_ignores_current_assistant_message() {
593        let repeated_response = "The task is complete";
594        let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
595        state.messages_mut().push(Message::assistant(repeated_response.to_string()));
596        let history = state.messages.clone();
597        state.push_warning("Existing warning");
598        state.consecutive_idle_turns = 3;
599
600        assert!(!check_for_response_loop(repeated_response, &mut state));
601        assert_eq!(state.warnings, ["Existing warning"]);
602        assert_eq!(state.consecutive_idle_turns, 3);
603        assert!(std::sync::Arc::ptr_eq(&history, &state.messages));
604    }
605
606    #[test]
607    fn response_loop_still_detects_prior_duplicate_assistant_message() {
608        let repeated_response = "The task is complete";
609        for prior_position in 0..2 {
610            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
611            for position in 0..2 {
612                let text = if position == prior_position {
613                    repeated_response
614                } else {
615                    "A different earlier response"
616                };
617                state.messages_mut().push(Message::assistant(text.to_string()));
618            }
619            state.messages_mut().push(Message::assistant(repeated_response.to_string()));
620
621            assert!(check_for_response_loop(repeated_response, &mut state), "prior position {prior_position}");
622        }
623    }
624
625    #[test]
626    fn response_loop_equates_ascii_and_unicode_whitespace() {
627        for response in [
628            "The task is complete",
629            " \tThe\r\ntask\u{000b}is\u{000c}complete  ",
630            "\u{00a0}The\u{2003}task\u{2028}is\u{3000}complete\u{0085}",
631        ] {
632            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
633            state
634                .messages_mut()
635                .push(Message::assistant("The\ttask  is\ncomplete".to_string()));
636            state.messages_mut().push(Message::assistant(response.to_string()));
637
638            assert!(check_for_response_loop(response, &mut state), "response: {response:?}");
639        }
640    }
641
642    #[test]
643    fn response_loop_equates_whitespace_only_responses() {
644        let response = " \t\n\r\u{2003}\u{3000}";
645        let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
646        state.messages_mut().push(Message::assistant("\n \t".to_string()));
647        state.messages_mut().push(Message::assistant(response.to_string()));
648
649        assert!(check_for_response_loop(response, &mut state));
650    }
651
652    #[test]
653    fn response_loop_rejects_different_word_sequences_without_mutating_state() {
654        for response in [
655            "The task complete is",
656            "the task is complete",
657            "The task is complete.",
658            "The task is",
659            "The task is complete now",
660            "Thetask is complete",
661            "The\u{200b}task is complete",
662        ] {
663            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
664            state
665                .messages_mut()
666                .push(Message::assistant("The task is complete".to_string()));
667            state.messages_mut().push(Message::assistant(response.to_string()));
668            let history = state.messages.clone();
669            state.push_warning("Existing warning");
670            state.consecutive_idle_turns = 3;
671
672            assert!(!check_for_response_loop(response, &mut state), "response: {response:?}");
673            assert_eq!(state.warnings, ["Existing warning"]);
674            assert_eq!(state.consecutive_idle_turns, 3);
675            assert!(std::sync::Arc::ptr_eq(&history, &state.messages));
676        }
677    }
678
679    #[test]
680    fn response_loop_ignores_duplicates_outside_prior_assistant_window() {
681        let response = "The task is complete";
682        let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
683        for text in [
684            response,
685            "Second older response",
686            "Most recent prior response",
687            response,
688        ] {
689            state.messages_mut().push(Message::assistant(text.to_string()));
690        }
691        let history = state.messages.clone();
692
693        assert!(!check_for_response_loop(response, &mut state));
694        assert!(state.warnings.is_empty());
695        assert_eq!(state.consecutive_idle_turns, 0);
696        assert!(std::sync::Arc::ptr_eq(&history, &state.messages));
697    }
698
699    #[test]
700    fn response_loop_filters_interleaved_user_and_tool_messages_before_windowing() {
701        let response = "The task is complete";
702        for prior_response in [response, "An unrelated prior response"] {
703            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
704            state.messages_mut().extend([
705                Message::assistant(prior_response.to_string()),
706                Message::user(response.to_string()),
707                Message::tool_response("older-call".to_string(), response.to_string()),
708                Message::assistant("Another prior response".to_string()),
709                Message::user(response.to_string()),
710                Message::tool_response("recent-call".to_string(), response.to_string()),
711                Message::assistant(response.to_string()),
712                Message::user(response.to_string()),
713                Message::tool_response("latest-call".to_string(), response.to_string()),
714            ]);
715
716            assert_eq!(check_for_response_loop(response, &mut state), prior_response == response);
717        }
718    }
719
720    #[test]
721    fn response_loop_preserves_ten_byte_cutoff() {
722        for response in ["123456789", "éééé", "1234567890", "ééééé"] {
723            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
724            state.messages_mut().push(Message::assistant(response.to_string()));
725            state.messages_mut().push(Message::assistant(response.to_string()));
726
727            let expected = matches!(response, "1234567890" | "ééééé");
728            assert_eq!(check_for_response_loop(response, &mut state), expected, "response: {response:?}");
729            assert_eq!(state.warnings.len(), usize::from(expected));
730            assert_eq!(state.consecutive_idle_turns, usize::from(expected));
731        }
732    }
733
734    #[test]
735    fn response_loop_preserves_multipart_text_concatenation() {
736        for response in ["The task is complete", "The ta sk is complete"] {
737            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
738            state.messages_mut().push(Message::assistant_with_parts(vec![
739                ContentPart::Text { text: "The ta".to_string() },
740                ContentPart::Text { text: "sk\tis complete".to_string() },
741            ]));
742            state.messages_mut().push(Message::assistant(response.to_string()));
743            let history = state.messages.clone();
744
745            assert_eq!(check_for_response_loop(response, &mut state), response == "The task is complete");
746            assert!(std::sync::Arc::ptr_eq(&history, &state.messages));
747        }
748    }
749
750    #[test]
751    fn response_loop_preserves_warning_and_saturating_idle_counter() {
752        let response = "The task is complete";
753        for initial_idle_turns in [3, usize::MAX] {
754            let mut state = AgentSessionState::new("session".to_string(), 8, 4, 128_000);
755            state.messages_mut().push(Message::assistant(response.to_string()));
756            state.messages_mut().push(Message::assistant(response.to_string()));
757            let history = state.messages.clone();
758            state.push_warning("Existing warning");
759            state.consecutive_idle_turns = initial_idle_turns;
760
761            assert!(check_for_response_loop(response, &mut state));
762            assert_eq!(state.consecutive_idle_turns, if initial_idle_turns == 3 { 4 } else { usize::MAX });
763            assert_eq!(
764                state.warnings,
765                [
766                    "Existing warning",
767                    "Repetitive assistant response detected. Breaking potential loop."
768                ]
769            );
770            assert!(std::sync::Arc::ptr_eq(&history, &state.messages));
771        }
772    }
773}