1use crate::core::agent::session::AgentSessionState;
2use crate::llm::provider::MessageRole;
3
4pub 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 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
39pub 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
55pub 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
72pub 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 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 false
105}
106
107pub 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
134pub 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
188pub fn check_completion_candidate(response_text: &str) -> bool {
190 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 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 if COMPLETION_SENTENCES.iter().any(|&s| response_lower.contains(s)) {
246 return true;
247 }
248
249 let trimmed = response_lower.trim();
251 for &indicator in SOFT_INDICATORS {
252 if trimmed.ends_with(indicator) || trimmed == indicator {
253 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 {
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
342pub 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 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 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 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 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}