1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::{ChatMessage, ImageAttachment, Message, MessageRole, ToolCallMessage};
6
7use crate::types::SessionId;
8
9#[derive(Clone, Debug, Default, Serialize, Deserialize)]
20pub struct RunState {
21 pub turn_tool_calls: usize,
25 pub run_has_tool_calls: bool,
29 pub reasoning_only_strikes: usize,
34 pub empty_response_strikes: usize,
39 pub nudge_count: usize,
43 #[serde(default)]
49 pub thinking_disabled_for_rest_of_run: bool,
50
51 #[serde(default)]
57 pub original_thinking_enabled: bool,
58
59 #[serde(default)]
65 pub truncation_strikes: usize,
66}
67
68impl RunState {
69 pub fn reset_for_new_run(&mut self) {
71 self.turn_tool_calls = 0;
72 self.run_has_tool_calls = false;
73 self.reasoning_only_strikes = 0;
74 self.empty_response_strikes = 0;
75 self.nudge_count = 0;
76 self.thinking_disabled_for_rest_of_run = false;
77 self.truncation_strikes = 0;
78 }
81
82 pub fn record_tool_calls(&mut self, n: usize) {
85 self.turn_tool_calls += n;
86 self.run_has_tool_calls = true;
87 self.reasoning_only_strikes = 0;
88 self.empty_response_strikes = 0;
89 self.truncation_strikes = 0;
90 }
91
92 pub fn record_reasoning_only(&mut self) -> usize {
97 self.empty_response_strikes = 0;
98 self.reasoning_only_strikes += 1;
99 if self.reasoning_only_strikes >= 3 {
100 tracing::info!(
101 strikes = self.reasoning_only_strikes,
102 "reasoning_only_strikes reached 3, disabling thinking for rest of run"
103 );
104 self.thinking_disabled_for_rest_of_run = true;
105 }
106 self.reasoning_only_strikes
107 }
108
109 pub fn record_empty_response(&mut self) -> usize {
113 self.reasoning_only_strikes = 0;
114 self.empty_response_strikes += 1;
115 self.empty_response_strikes
116 }
117
118 pub fn record_truncation(&mut self) -> usize {
121 self.truncation_strikes += 1;
122 self.truncation_strikes
123 }
124}
125
126#[derive(Deserialize)]
133struct RawAgentSession {
134 id: Option<SessionId>,
135 chat_messages: Vec<ChatMessage>,
136 always_allowed_actions: HashSet<String>,
137 total_tool_calls: usize,
138
139 run_state: Option<RunState>,
141
142 nudge_count: Option<usize>,
144 turn_tool_calls: Option<usize>,
145 reasoning_only_strikes: Option<usize>,
146 empty_response_strikes: Option<usize>,
147}
148
149impl From<RawAgentSession> for AgentSession {
150 fn from(raw: RawAgentSession) -> Self {
151 let run_state = raw.run_state.unwrap_or_else(|| RunState {
152 nudge_count: raw.nudge_count.unwrap_or(0),
153 turn_tool_calls: raw.turn_tool_calls.unwrap_or(0),
154 reasoning_only_strikes: raw.reasoning_only_strikes.unwrap_or(0),
155 empty_response_strikes: raw.empty_response_strikes.unwrap_or(0),
156 ..RunState::default()
157 });
158 Self {
159 id: raw.id,
160 chat_messages: raw.chat_messages,
161 always_allowed_actions: raw.always_allowed_actions,
162 total_tool_calls: raw.total_tool_calls,
163 run_state,
164 }
165 }
166}
167
168#[derive(Clone, Debug, Default, Serialize, Deserialize)]
173#[serde(from = "RawAgentSession")]
174pub struct AgentSession {
175 id: Option<SessionId>,
176 chat_messages: Vec<ChatMessage>,
179 always_allowed_actions: HashSet<String>,
180 pub total_tool_calls: usize,
183 pub run_state: RunState,
185}
186
187impl AgentSession {
188 pub fn new(id: SessionId) -> Self {
189 Self {
190 id: Some(id),
191 chat_messages: Vec::new(),
192 always_allowed_actions: HashSet::new(),
193 total_tool_calls: 0,
194 run_state: RunState::default(),
195 }
196 }
197
198 pub fn id(&self) -> Option<SessionId> {
199 self.id.clone()
200 }
201
202 pub fn simple_messages(&self) -> Vec<Message> {
206 self.chat_messages
207 .iter()
208 .filter_map(|cm| match cm {
209 ChatMessage::Assistant { content: None, .. } => None,
210 ChatMessage::Assistant {
211 content: Some(c),
212 tool_calls: Some(tc),
213 ..
214 } if c.is_empty() && !tc.is_empty() => None,
215 _ => Some(Message::from(cm)),
216 })
217 .collect()
218 }
219
220 pub fn chat_messages(&self) -> &[ChatMessage] {
221 &self.chat_messages
222 }
223
224 pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
226 &mut self.chat_messages
227 }
228
229 pub fn is_action_allowed(&self, action_key: &str) -> bool {
230 self.always_allowed_actions.contains(action_key)
231 }
232
233 pub fn allow_action(&mut self, action_key: impl Into<String>) {
234 self.always_allowed_actions.insert(action_key.into());
235 }
236
237 pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
238 let content = content.into();
239 let chat_msg = match role {
240 MessageRole::System => ChatMessage::system(content),
241 MessageRole::User => ChatMessage::user(content),
242 MessageRole::Assistant => ChatMessage::assistant(content),
243 MessageRole::Tool => ChatMessage::tool(String::new(), content),
244 };
245 self.chat_messages.push(chat_msg);
246 }
247
248 pub fn push_assistant_with_reasoning(
252 &mut self,
253 content: impl Into<String>,
254 reasoning: impl Into<String>,
255 ) {
256 self.chat_messages
257 .push(ChatMessage::assistant_with_reasoning(content, reasoning));
258 }
259
260 pub fn push_user_message_with_images(
261 &mut self,
262 content: impl Into<String>,
263 images: Vec<ImageAttachment>,
264 ) {
265 self.chat_messages
266 .push(ChatMessage::user_with_images(content, images));
267 }
268
269 pub fn push_assistant_tool_call(
270 &mut self,
271 tool_call_id: &str,
272 tool_name: &str,
273 arguments_json: &str,
274 ) {
275 self.chat_messages.push(ChatMessage::assistant_tool_call(
276 tool_call_id,
277 tool_name,
278 arguments_json,
279 ));
280 }
281
282 pub fn push_assistant_tool_calls(
283 &mut self,
284 tool_calls: &[(String, String, String)],
285 reasoning: Option<String>,
286 content: Option<String>,
287 ) {
288 let calls: Vec<ToolCallMessage> = tool_calls
289 .iter()
290 .map(|(id, name, args)| {
291 let valid_args = if serde_json::from_str::<serde_json::Value>(args).is_ok() {
301 args.clone()
302 } else {
303 tracing::warn!(
304 tool_name = %name,
305 args_len = args.len(),
306 "tool call arguments are not valid JSON (provider truncated them), \
307 sanitizing to empty object; re-issue instruction goes to the tool_result"
308 );
309 "{}".to_string()
310 };
311 ToolCallMessage {
312 id: id.clone(),
313 name: name.clone(),
314 arguments: valid_args,
315 }
316 })
317 .collect();
318 self.chat_messages.push(ChatMessage::Assistant {
319 content,
320 reasoning_content: reasoning,
321 tool_calls: Some(calls),
322 thinking_signature: None,
323 });
324 }
325
326 pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
327 self.chat_messages
328 .push(ChatMessage::tool(tool_call_id, content));
329 }
330
331 pub fn remove_ephemeral_messages(&mut self) {
335 let before = self.chat_messages.len();
336 self.chat_messages.retain(|m| !m.is_ephemeral());
337 let removed = before - self.chat_messages.len();
338 if removed > 0 {
339 tracing::debug!(
340 removed,
341 remaining = self.chat_messages.len(),
342 "ephemeral messages cleaned up"
343 );
344 }
345 }
346
347 pub fn turn_count(&self) -> usize {
350 self.chat_messages
351 .iter()
352 .filter(|m| matches!(m, ChatMessage::User { .. }))
353 .count()
354 }
355
356 pub fn trim_oldest_turns(&mut self, max_turns: usize) {
359 let current_turns = self.turn_count();
360 if current_turns <= max_turns {
361 return;
362 }
363 let turns_to_remove = current_turns - max_turns;
364
365 let user_positions: Vec<usize> = self
367 .chat_messages
368 .iter()
369 .enumerate()
370 .filter_map(|(i, m)| {
371 if matches!(m, ChatMessage::User { .. }) {
372 Some(i)
373 } else {
374 None
375 }
376 })
377 .collect();
378
379 if user_positions.len() <= turns_to_remove {
380 return;
381 }
382
383 let system_prefix = self
385 .chat_messages
386 .iter()
387 .take_while(|m| matches!(m, ChatMessage::System { .. }))
388 .count();
389
390 let drain_end = user_positions[turns_to_remove];
392 if system_prefix >= drain_end {
393 return; }
395
396 self.chat_messages.drain(system_prefix..drain_end);
397 }
398
399 pub fn pop_last_message(&mut self) {
402 self.chat_messages.pop();
403 }
404
405 pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
406 let assistant_idx = self.chat_messages.iter().rposition(
407 |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
408 );
409
410 let Some(assistant_idx) = assistant_idx else {
411 return;
412 };
413
414 let ChatMessage::Assistant {
415 tool_calls: Some(tc),
416 ..
417 } = &self.chat_messages[assistant_idx]
418 else {
419 return;
420 };
421
422 let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
423
424 let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
425 .iter()
426 .filter_map(|m| match m {
427 ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
428 _ => None,
429 })
430 .collect();
431
432 for id in &all_ids {
433 if !answered_ids.iter().any(|a| a == id) {
434 self.push_tool_result(id, error_summary);
435 }
436 }
437 }
438
439 pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
444 validate_message_sequence(&messages)?;
445 self.total_tool_calls = messages
448 .iter()
449 .filter_map(|m| match m {
450 ChatMessage::Assistant {
451 tool_calls: Some(tc),
452 ..
453 } => Some(tc.len()),
454 _ => None,
455 })
456 .sum();
457 self.chat_messages = messages;
458 Ok(())
459 }
460}
461
462pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
475 if !messages
476 .iter()
477 .any(|m| !matches!(m, ChatMessage::System { .. } | ChatMessage::Custom { .. }))
478 {
479 return Err(
480 "sequence contains no sendable message: System/Custom alone leave the \
481 provider `messages` array empty"
482 .to_string(),
483 );
484 }
485
486 let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
487
488 for (i, msg) in messages.iter().enumerate() {
489 match msg {
490 ChatMessage::Tool { tool_call_id, .. } => {
491 if pending_tool_call_ids.is_empty() {
492 return Err(format!(
493 "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
494 i, tool_call_id
495 ));
496 }
497 if !pending_tool_call_ids.remove(tool_call_id) {
499 return Err(format!(
500 "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
501 i, tool_call_id
502 ));
503 }
504 }
505 ChatMessage::Assistant {
506 tool_calls: Some(tc),
507 ..
508 } => {
509 if !pending_tool_call_ids.is_empty() {
511 return Err(format!(
512 "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
513 i, pending_tool_call_ids
514 ));
515 }
516 pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
517 }
518 _ => {}
519 }
520 }
521
522 if !pending_tool_call_ids.is_empty() {
524 return Err(format!(
525 "message sequence ends with unanswered tool calls: {:?}",
526 pending_tool_call_ids
527 ));
528 }
529
530 Ok(())
531}
532
533#[cfg(test)]
534fn make_session() -> AgentSession {
535 AgentSession::new(SessionId::new(1))
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_turn_count_empty() {
544 let s = make_session();
545 assert_eq!(s.turn_count(), 0);
546 }
547
548 #[test]
549 fn test_turn_count_with_system_and_user() {
550 let mut s = make_session();
551 s.push_message(MessageRole::System, "system");
552 assert_eq!(s.turn_count(), 0);
553 s.push_message(MessageRole::User, "hello");
554 assert_eq!(s.turn_count(), 1);
555 s.push_message(MessageRole::Assistant, "hi");
556 assert_eq!(s.turn_count(), 1);
557 s.push_message(MessageRole::User, "bye");
558 assert_eq!(s.turn_count(), 2);
559 }
560
561 #[test]
562 fn test_turn_count_with_tool_calls() {
563 let mut s = make_session();
564 s.push_message(MessageRole::User, "do something");
565 s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None, None);
566 s.push_tool_result("id1", "result");
567 s.push_message(MessageRole::Assistant, "done");
568 assert_eq!(s.turn_count(), 1);
570 }
571
572 #[test]
573 fn test_trim_oldest_turns_noop() {
574 let mut s = make_session();
575 s.push_message(MessageRole::User, "hello");
576 s.push_message(MessageRole::Assistant, "hi");
577 s.trim_oldest_turns(5);
578 assert_eq!(s.turn_count(), 1);
579 assert_eq!(s.chat_messages().len(), 2);
580 }
581
582 #[test]
583 fn test_trim_oldest_turns_removes_old() {
584 let mut s = make_session();
585 s.push_message(MessageRole::System, "sys");
586 s.push_message(MessageRole::User, "u1");
588 s.push_message(MessageRole::Assistant, "a1");
589 s.push_message(MessageRole::User, "u2");
591 s.push_message(MessageRole::Assistant, "a2");
592 s.push_message(MessageRole::User, "u3");
594 s.push_message(MessageRole::Assistant, "a3");
595
596 s.trim_oldest_turns(2);
597 assert_eq!(s.turn_count(), 2);
598 assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
600 assert!(
602 matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
603 );
604 }
605
606 #[test]
607 fn test_trim_oldest_turns_with_tool_calls() {
608 let mut s = make_session();
609 s.push_message(MessageRole::User, "u1");
611 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None, None);
612 s.push_tool_result("id1", "r1");
613 s.push_message(MessageRole::Assistant, "a1");
614 s.push_message(MessageRole::User, "u2");
616 s.push_message(MessageRole::Assistant, "a2");
617
618 let msg_before = s.simple_messages().len();
619 let chat_before = s.chat_messages().len();
620 s.trim_oldest_turns(1);
621 assert_eq!(s.turn_count(), 1);
622 assert_eq!(s.chat_messages().len(), chat_before - 4);
624 assert_eq!(s.simple_messages().len(), msg_before - 3);
626 }
627
628 #[test]
629 fn test_pop_last_message_text() {
630 let mut s = make_session();
631 s.push_message(MessageRole::User, "hello");
632 s.push_message(MessageRole::Assistant, "hi");
633 assert_eq!(s.chat_messages().len(), 2);
634 s.pop_last_message();
635 assert_eq!(s.chat_messages().len(), 1);
636 assert_eq!(s.simple_messages().len(), 1);
637 }
638
639 #[test]
640 fn test_pop_last_message_tool_calls_only() {
641 let mut s = make_session();
642 s.push_message(MessageRole::User, "do it");
643 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None, None);
644 assert_eq!(s.chat_messages().len(), 2);
645 assert_eq!(s.simple_messages().len(), 1); s.pop_last_message();
647 assert_eq!(s.chat_messages().len(), 1);
648 assert_eq!(s.simple_messages().len(), 1); }
650
651 #[test]
652 fn test_pop_last_message_empty_session() {
653 let mut s = make_session();
654 s.pop_last_message(); assert_eq!(s.chat_messages().len(), 0);
656 }
657
658 #[test]
661 fn test_id_and_action_allowlist() {
662 let mut s = make_session();
663 assert_eq!(s.id(), Some(SessionId::new(1)));
664 assert!(!s.is_action_allowed("approve:rm"));
665 s.allow_action("approve:rm");
666 assert!(s.is_action_allowed("approve:rm"));
667 assert!(!s.is_action_allowed("approve:shell"));
668 }
669
670 #[test]
671 fn test_chat_messages_mut() {
672 let mut s = make_session();
673 s.chat_messages_mut().push(ChatMessage::user("direct"));
674 assert_eq!(s.chat_messages().len(), 1);
675 }
676
677 #[test]
678 fn test_push_message_tool_role() {
679 let mut s = make_session();
680 s.push_message(MessageRole::Tool, "result");
681 assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
682 }
683
684 #[test]
685 fn test_push_assistant_with_reasoning() {
686 let mut s = make_session();
687 s.push_assistant_with_reasoning("answer", "thinking");
688 match &s.chat_messages()[0] {
689 ChatMessage::Assistant {
690 content,
691 reasoning_content,
692 ..
693 } => {
694 assert_eq!(content.as_deref(), Some("answer"));
695 assert_eq!(reasoning_content.as_deref(), Some("thinking"));
696 }
697 other => panic!("unexpected message: {other:?}"),
698 }
699 }
700
701 #[test]
702 fn test_push_user_message_with_images() {
703 let mut s = make_session();
704 s.push_user_message_with_images(
705 "look",
706 vec![ImageAttachment::Url {
707 url: "http://x".into(),
708 detail: None,
709 }],
710 );
711 match &s.chat_messages()[0] {
712 ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
713 other => panic!("unexpected message: {other:?}"),
714 }
715 }
716
717 #[test]
718 fn test_push_assistant_tool_call_singular() {
719 let mut s = make_session();
720 s.push_assistant_tool_call("call_1", "bash", "{}");
721 match &s.chat_messages()[0] {
722 ChatMessage::Assistant {
723 tool_calls: Some(tc),
724 ..
725 } => {
726 assert_eq!(tc.len(), 1);
727 assert_eq!(tc[0].id, "call_1");
728 assert_eq!(tc[0].name, "bash");
729 }
730 other => panic!("unexpected message: {other:?}"),
731 }
732 }
733
734 #[test]
735 fn test_simple_messages_filters_empty_content_tool_calls() {
736 let mut s = make_session();
737 s.chat_messages_mut().push(ChatMessage::Assistant {
738 content: Some(String::new()),
739 reasoning_content: None,
740 tool_calls: Some(vec![ToolCallMessage {
741 id: "c".into(),
742 name: "t".into(),
743 arguments: "{}".into(),
744 }]),
745 thinking_signature: None,
746 });
747 assert!(s.simple_messages().is_empty());
748 }
749
750 #[test]
751 fn test_remove_ephemeral_messages() {
752 let mut s = make_session();
753 s.push_message(MessageRole::System, "keep");
754 s.chat_messages_mut()
755 .push(ChatMessage::user_ephemeral("temp"));
756 s.chat_messages_mut()
757 .push(ChatMessage::system_ephemeral("temp2"));
758 s.push_message(MessageRole::User, "keep2");
759 assert_eq!(s.chat_messages().len(), 4);
760 s.remove_ephemeral_messages();
761 assert_eq!(s.chat_messages().len(), 2);
762 assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
763 }
764
765 #[test]
766 fn test_close_dangling_tool_calls_noop_without_tool_call() {
767 let mut s = make_session();
768 s.push_message(MessageRole::User, "hi");
769 s.push_message(MessageRole::Assistant, "hi");
770 s.close_dangling_tool_calls("failed");
771 assert_eq!(s.chat_messages().len(), 2);
772 }
773
774 #[test]
775 fn test_close_dangling_tool_calls_adds_missing_results() {
776 let mut s = make_session();
777 s.push_message(MessageRole::User, "do");
778 s.push_assistant_tool_calls(
779 &[
780 ("c1".into(), "t".into(), "{}".into()),
781 ("c2".into(), "t".into(), "{}".into()),
782 ],
783 None,
784 None,
785 );
786 s.push_tool_result("c1", "ok"); s.close_dangling_tool_calls("failed");
788
789 let tool_results: Vec<(String, String)> = s
790 .chat_messages()
791 .iter()
792 .filter_map(|m| match m {
793 ChatMessage::Tool {
794 tool_call_id,
795 name: _,
796 content,
797 } => Some((tool_call_id.clone(), content.clone())),
798 _ => None,
799 })
800 .collect();
801 assert_eq!(tool_results.len(), 2);
802 assert!(
803 tool_results
804 .iter()
805 .any(|(id, c)| id == "c2" && c == "failed")
806 );
807 }
808
809 #[test]
810 fn test_set_chat_messages_recalculates_total_tool_calls() {
811 let mut s = make_session();
812 let msgs = vec![
813 ChatMessage::user("do"),
814 ChatMessage::assistant_tool_call("c1", "t", "{}"),
815 ChatMessage::tool("c1", "result"),
816 ];
817 s.set_chat_messages(msgs).unwrap();
818 assert_eq!(s.total_tool_calls, 1);
819 }
820}
821
822#[cfg(test)]
823mod validate_tests {
824 use super::*;
825
826 #[test]
827 fn test_valid_simple_sequence() {
828 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
829 assert!(validate_message_sequence(&msgs).is_ok());
830 }
831
832 #[test]
833 fn test_valid_tool_call_sequence() {
834 let msgs = vec![
835 ChatMessage::user("run command"),
836 ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
837 ChatMessage::tool("call_1", "file1 file2"),
838 ChatMessage::assistant("done"),
839 ];
840 assert!(validate_message_sequence(&msgs).is_ok());
841 }
842
843 #[test]
844 fn test_valid_multi_tool_call_sequence() {
845 let msgs = vec![
846 ChatMessage::user("run commands"),
847 ChatMessage::Assistant {
848 content: None,
849 reasoning_content: None,
850 tool_calls: Some(vec![
851 crate::types::ToolCallMessage {
852 id: "call_1".into(),
853 name: "bash".into(),
854 arguments: "{}".into(),
855 },
856 crate::types::ToolCallMessage {
857 id: "call_2".into(),
858 name: "read".into(),
859 arguments: "{}".into(),
860 },
861 ]),
862 thinking_signature: None,
863 },
864 ChatMessage::tool("call_1", "result1"),
865 ChatMessage::tool("call_2", "result2"),
866 ChatMessage::assistant("done"),
867 ];
868 assert!(validate_message_sequence(&msgs).is_ok());
869 }
870
871 #[test]
872 fn test_orphaned_tool_result() {
873 let msgs = vec![
874 ChatMessage::user("hello"),
875 ChatMessage::tool("call_1", "orphaned result"),
876 ];
877 let err = validate_message_sequence(&msgs).unwrap_err();
878 assert!(err.contains("no preceding tool_call"));
879 }
880
881 #[test]
882 fn test_system_only_sequence_rejected() {
883 let msgs = vec![
887 ChatMessage::system("prompt"),
888 ChatMessage::system_ephemeral("reminder"),
889 ChatMessage::Custom {
890 role: "artifact".into(),
891 data: serde_json::json!({"id": "x"}),
892 },
893 ];
894 let err = validate_message_sequence(&msgs).unwrap_err();
895 assert!(err.contains("no sendable message"));
896 }
897
898 #[test]
899 fn test_system_plus_user_ok() {
900 let msgs = vec![ChatMessage::system("prompt"), ChatMessage::user("hi")];
901 assert!(validate_message_sequence(&msgs).is_ok());
902 }
903
904 #[test]
905 fn test_mismatched_tool_call_id() {
906 let msgs = vec![
907 ChatMessage::user("run"),
908 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
909 ChatMessage::tool("call_2", "wrong id"),
910 ];
911 let err = validate_message_sequence(&msgs).unwrap_err();
912 assert!(err.contains("does not match"));
913 }
914
915 #[test]
916 fn test_set_chat_messages_valid() {
917 let mut s = make_session();
918 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
919 assert!(s.set_chat_messages(msgs.clone()).is_ok());
920 assert_eq!(s.chat_messages().len(), 2);
921 }
922
923 #[test]
924 fn test_set_chat_messages_invalid() {
925 let mut s = make_session();
926 let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
927 assert!(s.set_chat_messages(msgs).is_err());
928 }
929
930 #[test]
933 fn run_state_default() {
934 let rs = RunState::default();
935 assert_eq!(rs.turn_tool_calls, 0);
936 assert!(!rs.run_has_tool_calls);
937 assert_eq!(rs.reasoning_only_strikes, 0);
938 assert_eq!(rs.empty_response_strikes, 0);
939 assert_eq!(rs.nudge_count, 0);
940 }
941
942 #[test]
943 fn run_state_reset_for_new_run() {
944 let mut rs = RunState {
945 turn_tool_calls: 5,
946 run_has_tool_calls: true,
947 reasoning_only_strikes: 2,
948 empty_response_strikes: 1,
949 nudge_count: 3,
950 thinking_disabled_for_rest_of_run: true,
951 original_thinking_enabled: true,
952 truncation_strikes: 4,
953 };
954
955 rs.reset_for_new_run();
956
957 assert_eq!(rs.turn_tool_calls, 0);
958 assert!(!rs.run_has_tool_calls);
959 assert_eq!(rs.reasoning_only_strikes, 0);
960 assert_eq!(rs.empty_response_strikes, 0);
961 assert_eq!(rs.nudge_count, 0);
962 assert_eq!(rs.truncation_strikes, 0);
963 assert!(!rs.thinking_disabled_for_rest_of_run);
964 assert!(rs.original_thinking_enabled);
966 }
967
968 #[test]
969 fn run_state_record_tool_calls() {
970 let mut rs = RunState {
971 reasoning_only_strikes: 2,
972 empty_response_strikes: 1,
973 ..RunState::default()
974 };
975
976 rs.record_tool_calls(3);
977
978 assert_eq!(rs.turn_tool_calls, 3);
979 assert!(rs.run_has_tool_calls);
980 assert_eq!(rs.reasoning_only_strikes, 0); assert_eq!(rs.empty_response_strikes, 0); }
983
984 #[test]
985 fn run_state_record_tool_calls_accumulates() {
986 let mut rs = RunState::default();
987 rs.record_tool_calls(2);
988 rs.record_tool_calls(3);
989
990 assert_eq!(rs.turn_tool_calls, 5);
991 assert!(rs.run_has_tool_calls);
992 }
993
994 #[test]
995 fn run_state_record_reasoning_only() {
996 let mut rs = RunState {
997 empty_response_strikes: 2,
998 ..RunState::default()
999 };
1000
1001 let strikes = rs.record_reasoning_only();
1002
1003 assert_eq!(strikes, 1);
1004 assert_eq!(rs.reasoning_only_strikes, 1);
1005 assert_eq!(rs.empty_response_strikes, 0); }
1007
1008 #[test]
1009 fn run_state_record_reasoning_only_consecutive() {
1010 let mut rs = RunState::default();
1011
1012 assert_eq!(rs.record_reasoning_only(), 1);
1013 assert_eq!(rs.record_reasoning_only(), 2);
1014 assert_eq!(rs.record_reasoning_only(), 3);
1015 }
1016
1017 #[test]
1018 fn run_state_record_empty_response() {
1019 let mut rs = RunState {
1020 reasoning_only_strikes: 2,
1021 ..RunState::default()
1022 };
1023
1024 let strikes = rs.record_empty_response();
1025
1026 assert_eq!(strikes, 1);
1027 assert_eq!(rs.empty_response_strikes, 1);
1028 assert_eq!(rs.reasoning_only_strikes, 0); }
1030
1031 #[test]
1032 fn run_state_record_empty_response_consecutive() {
1033 let mut rs = RunState::default();
1034
1035 assert_eq!(rs.record_empty_response(), 1);
1036 assert_eq!(rs.record_empty_response(), 2);
1037 assert_eq!(rs.record_empty_response(), 3);
1038 }
1039
1040 #[test]
1041 fn run_state_branch_cross_reset() {
1042 let mut rs = RunState::default();
1044
1045 rs.record_reasoning_only();
1047 assert_eq!(rs.reasoning_only_strikes, 1);
1048
1049 rs.record_tool_calls(2);
1051 assert_eq!(rs.reasoning_only_strikes, 0);
1052 assert_eq!(rs.turn_tool_calls, 2);
1053
1054 let strikes = rs.record_reasoning_only();
1056 assert_eq!(strikes, 1);
1057 }
1058
1059 #[test]
1060 fn run_state_empty_to_reasoning_reset() {
1061 let mut rs = RunState::default();
1063
1064 rs.record_empty_response();
1065 rs.record_empty_response();
1066 assert_eq!(rs.empty_response_strikes, 2);
1067
1068 rs.record_reasoning_only();
1070 assert_eq!(rs.empty_response_strikes, 0);
1071 assert_eq!(rs.reasoning_only_strikes, 1);
1072 }
1073
1074 #[test]
1075 fn run_state_thinking_disabled_default() {
1076 let rs = RunState::default();
1077 assert!(!rs.thinking_disabled_for_rest_of_run);
1078 }
1079
1080 #[test]
1081 fn run_state_thinking_disabled_after_3_strikes() {
1082 let mut rs = RunState::default();
1083
1084 rs.record_reasoning_only();
1086 assert!(!rs.thinking_disabled_for_rest_of_run);
1087 assert_eq!(rs.reasoning_only_strikes, 1);
1088
1089 rs.record_reasoning_only();
1091 assert!(!rs.thinking_disabled_for_rest_of_run);
1092 assert_eq!(rs.reasoning_only_strikes, 2);
1093
1094 rs.record_reasoning_only();
1096 assert!(rs.thinking_disabled_for_rest_of_run);
1097 assert_eq!(rs.reasoning_only_strikes, 3);
1098 }
1099
1100 #[test]
1101 fn run_state_thinking_disabled_resets_on_new_run() {
1102 let mut rs = RunState::default();
1103
1104 rs.record_reasoning_only();
1106 rs.record_reasoning_only();
1107 rs.record_reasoning_only();
1108 assert!(rs.thinking_disabled_for_rest_of_run);
1109 assert_eq!(rs.reasoning_only_strikes, 3);
1110
1111 rs.reset_for_new_run();
1113 assert!(!rs.thinking_disabled_for_rest_of_run);
1114 assert_eq!(rs.reasoning_only_strikes, 0);
1115 }
1116
1117 #[test]
1118 fn run_state_thinking_disabled_stays_after_tool_calls() {
1119 let mut rs = RunState::default();
1120
1121 rs.record_reasoning_only();
1123 rs.record_reasoning_only();
1124 rs.record_reasoning_only();
1125 assert!(rs.thinking_disabled_for_rest_of_run);
1126
1127 rs.record_tool_calls(2);
1130 assert!(rs.thinking_disabled_for_rest_of_run);
1131 assert_eq!(rs.reasoning_only_strikes, 0); }
1133
1134 #[test]
1137 fn deserialize_legacy_flat_fields() {
1138 let json = r#"{
1140 "id": null,
1141 "chat_messages": [],
1142 "always_allowed_actions": [],
1143 "total_tool_calls": 5,
1144 "nudge_count": 3,
1145 "turn_tool_calls": 2,
1146 "reasoning_only_strikes": 1,
1147 "empty_response_strikes": 0
1148 }"#;
1149 let session: AgentSession = serde_json::from_str(json).unwrap();
1150 assert_eq!(session.run_state.nudge_count, 3);
1151 assert_eq!(session.run_state.turn_tool_calls, 2);
1152 assert_eq!(session.run_state.reasoning_only_strikes, 1);
1153 assert_eq!(session.run_state.empty_response_strikes, 0);
1154 assert!(!session.run_state.run_has_tool_calls); }
1156
1157 #[test]
1158 fn deserialize_new_run_state_format() {
1159 let json = r#"{
1161 "id": null,
1162 "chat_messages": [],
1163 "always_allowed_actions": [],
1164 "total_tool_calls": 5,
1165 "run_state": {
1166 "turn_tool_calls": 4,
1167 "run_has_tool_calls": true,
1168 "reasoning_only_strikes": 0,
1169 "empty_response_strikes": 1,
1170 "nudge_count": 2
1171 }
1172 }"#;
1173 let session: AgentSession = serde_json::from_str(json).unwrap();
1174 assert_eq!(session.run_state.turn_tool_calls, 4);
1175 assert!(session.run_state.run_has_tool_calls);
1176 assert_eq!(session.run_state.empty_response_strikes, 1);
1177 assert_eq!(session.run_state.nudge_count, 2);
1178 }
1179
1180 #[test]
1181 fn deserialize_run_state_takes_precedence_over_flat() {
1182 let json = r#"{
1184 "id": null,
1185 "chat_messages": [],
1186 "always_allowed_actions": [],
1187 "total_tool_calls": 0,
1188 "run_state": {
1189 "turn_tool_calls": 10,
1190 "run_has_tool_calls": true,
1191 "reasoning_only_strikes": 0,
1192 "empty_response_strikes": 0,
1193 "nudge_count": 0
1194 },
1195 "nudge_count": 99,
1196 "turn_tool_calls": 99
1197 }"#;
1198 let session: AgentSession = serde_json::from_str(json).unwrap();
1199 assert_eq!(session.run_state.turn_tool_calls, 10); assert_eq!(session.run_state.nudge_count, 0); }
1202
1203 #[test]
1204 fn deserialize_legacy_missing_optional_fields() {
1205 let json = r#"{
1207 "id": null,
1208 "chat_messages": [],
1209 "always_allowed_actions": [],
1210 "total_tool_calls": 0,
1211 "nudge_count": 1
1212 }"#;
1213 let session: AgentSession = serde_json::from_str(json).unwrap();
1214 assert_eq!(session.run_state.nudge_count, 1);
1215 assert_eq!(session.run_state.turn_tool_calls, 0); assert_eq!(session.run_state.reasoning_only_strikes, 0);
1217 assert_eq!(session.run_state.empty_response_strikes, 0);
1218 }
1219
1220 #[test]
1221 fn roundtrip_preserves_run_state() {
1222 let mut session = AgentSession::new(SessionId::new(1));
1223 session.run_state.nudge_count = 5;
1224 session.run_state.turn_tool_calls = 3;
1225 session.run_state.run_has_tool_calls = true;
1226 session.run_state.reasoning_only_strikes = 2;
1227
1228 let json = serde_json::to_string(&session).unwrap();
1229 let restored: AgentSession = serde_json::from_str(&json).unwrap();
1230 assert_eq!(restored.run_state.nudge_count, 5);
1231 assert_eq!(restored.run_state.turn_tool_calls, 3);
1232 assert!(restored.run_state.run_has_tool_calls);
1233 assert_eq!(restored.run_state.reasoning_only_strikes, 2);
1234 }
1235
1236 #[test]
1237 fn push_assistant_tool_calls_validates_json_args() {
1238 let mut s = make_session();
1239
1240 let valid_args = r#"{"path": "src/main.rs", "content": "fn main() {}"}"#;
1242 s.push_assistant_tool_calls(
1243 &[("id1".into(), "write_file".into(), valid_args.into())],
1244 None,
1245 None,
1246 );
1247 if let ChatMessage::Assistant {
1248 tool_calls: Some(ref tc),
1249 ..
1250 } = s.chat_messages[0]
1251 {
1252 assert_eq!(tc[0].arguments, valid_args);
1253 } else {
1254 panic!("expected Assistant message with tool_calls");
1255 }
1256
1257 let truncated_args = r#"{"path": "src/ui/markdown.rs", "content": "#;
1267 s.push_assistant_tool_calls(
1268 &[("id2".into(), "write_file".into(), truncated_args.into())],
1269 None,
1270 None,
1271 );
1272 if let ChatMessage::Assistant {
1273 tool_calls: Some(ref tc),
1274 ..
1275 } = s.chat_messages[1]
1276 {
1277 assert_eq!(tc[0].arguments, "{}");
1278 assert!(
1279 !tc[0].arguments.contains("tool_call_arguments_truncated"),
1280 "assistant arguments must not carry the poison wrapper object"
1281 );
1282 } else {
1283 panic!("expected Assistant message with tool_calls");
1284 }
1285 }
1286
1287 #[test]
1288 fn push_assistant_tool_calls_truncated_multibyte_no_panic() {
1289 let mut s = make_session();
1292
1293 let mut bad_args = "あ".repeat(70); bad_args.push_str("truncated"); s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), bad_args)], None, None);
1301
1302 if let ChatMessage::Assistant {
1303 tool_calls: Some(ref tc),
1304 ..
1305 } = s.chat_messages[0]
1306 {
1307 assert_eq!(tc[0].arguments, "{}");
1308 } else {
1309 panic!("expected Assistant message with tool_calls");
1310 }
1311 }
1312
1313 #[test]
1314 fn push_assistant_tool_calls_then_tool_result_matches_anthropic_protocol() {
1315 let mut s = make_session();
1320
1321 let tool_calls = vec![
1323 (
1324 "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883".to_string(),
1325 "write_file".to_string(),
1326 "{}".to_string(),
1327 ),
1328 (
1329 "call_01_abc123".to_string(),
1330 "bash".to_string(),
1331 r#"{"command": "ls"}"#.to_string(),
1332 ),
1333 ];
1334
1335 s.push_assistant_tool_calls(
1337 &tool_calls,
1338 Some("thinking...".to_string()),
1339 Some("I'll help you".to_string()),
1340 );
1341
1342 for (tc_id, _, _) in &tool_calls {
1344 s.push_tool_result(
1345 tc_id,
1346 "Tool call was not executed: the response hit the output token limit.",
1347 );
1348 }
1349
1350 if let ChatMessage::Assistant {
1353 tool_calls: Some(ref tc),
1354 ..
1355 } = s.chat_messages[0]
1356 {
1357 assert_eq!(tc.len(), 2);
1358 assert_eq!(tc[0].id, "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883");
1359 assert_eq!(tc[0].name, "write_file");
1360 assert_eq!(tc[1].id, "call_01_abc123");
1361 assert_eq!(tc[1].name, "bash");
1362 } else {
1363 panic!("expected Assistant message with tool_calls");
1364 }
1365
1366 if let ChatMessage::Tool { tool_call_id, .. } = &s.chat_messages[1] {
1368 assert_eq!(tool_call_id, "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883");
1369 } else {
1370 panic!("expected Tool message for first tool call");
1371 }
1372
1373 if let ChatMessage::Tool { tool_call_id, .. } = &s.chat_messages[2] {
1374 assert_eq!(tool_call_id, "call_01_abc123");
1375 } else {
1376 panic!("expected Tool message for second tool call");
1377 }
1378
1379 assert!(
1381 validate_message_sequence(&s.chat_messages).is_ok(),
1382 "message sequence should be valid with matching tool_use and tool_result"
1383 );
1384 }
1385}
1386
1387#[cfg(test)]
1388mod proptest_tests {
1389 use super::*;
1390 use proptest::prelude::*;
1391
1392 proptest! {
1393 #[test]
1396 fn reset_for_new_run_zeros_all_fields(
1397 turn_tool_calls in 0usize..1000,
1398 run_has_tool_calls in proptest::bool::ANY,
1399 reasoning_only_strikes in 0usize..100,
1400 empty_response_strikes in 0usize..100,
1401 nudge_count in 0usize..100,
1402 ) {
1403 let mut rs = RunState {
1404 turn_tool_calls,
1405 run_has_tool_calls,
1406 reasoning_only_strikes,
1407 empty_response_strikes,
1408 nudge_count,
1409 thinking_disabled_for_rest_of_run: true,
1410 original_thinking_enabled: true,
1411 truncation_strikes: 5,
1412 };
1413 rs.reset_for_new_run();
1414 assert_eq!(rs.turn_tool_calls, 0);
1415 assert!(!rs.run_has_tool_calls);
1416 assert_eq!(rs.reasoning_only_strikes, 0);
1417 assert_eq!(rs.empty_response_strikes, 0);
1418 assert_eq!(rs.nudge_count, 0);
1419 assert_eq!(rs.truncation_strikes, 0);
1420 assert!(!rs.thinking_disabled_for_rest_of_run);
1421 assert!(rs.original_thinking_enabled);
1423 }
1424
1425 #[test]
1426 fn record_tool_calls_accumulates(n in 0usize..100) {
1427 let mut rs = RunState::default();
1428 rs.record_tool_calls(n);
1429 assert_eq!(rs.turn_tool_calls, n);
1430 assert!(rs.run_has_tool_calls);
1431 assert_eq!(rs.reasoning_only_strikes, 0);
1432 assert_eq!(rs.empty_response_strikes, 0);
1433 }
1434
1435 #[test]
1436 fn record_reasoning_only_increments(count in 1usize..50) {
1437 let mut rs = RunState::default();
1438 for i in 1..=count {
1439 let strikes = rs.record_reasoning_only();
1440 assert_eq!(strikes, i);
1441 assert_eq!(rs.empty_response_strikes, 0);
1442 }
1443 }
1444
1445 #[test]
1446 fn record_empty_response_increments(count in 1usize..50) {
1447 let mut rs = RunState::default();
1448 for i in 1..=count {
1449 let strikes = rs.record_empty_response();
1450 assert_eq!(strikes, i);
1451 assert_eq!(rs.reasoning_only_strikes, 0);
1452 }
1453 }
1454
1455 #[test]
1458 fn push_assistant_tool_calls_valid_json_unchanged(args in r"\{[^{}]{0,200}\}") {
1459 if serde_json::from_str::<serde_json::Value>(&args).is_err() {
1461 return Ok(());
1462 }
1463 let mut s = make_session();
1464 s.push_assistant_tool_calls(
1465 &[("id".into(), "tool".into(), args.clone())],
1466 None,
1467 None,
1468 );
1469 if let ChatMessage::Assistant { tool_calls: Some(ref tc), .. } = s.chat_messages()[0] {
1470 assert_eq!(tc[0].arguments, args);
1471 } else {
1472 panic!("expected Assistant with tool_calls");
1473 }
1474 }
1475
1476 #[test]
1477 fn push_assistant_tool_calls_invalid_json_sanitized_to_empty(
1478 bad_args in "[a-z\u{4e00}-\u{9fff}]{0,300}"
1479 ) {
1480 if serde_json::from_str::<serde_json::Value>(&bad_args).is_ok() {
1482 return Ok(());
1483 }
1484 let mut s = make_session();
1485 s.push_assistant_tool_calls(
1486 &[("id".into(), "tool".into(), bad_args)],
1487 None,
1488 None,
1489 );
1490 if let ChatMessage::Assistant { tool_calls: Some(ref tc), .. } = s.chat_messages()[0] {
1491 serde_json::from_str::<serde_json::Value>(&tc[0].arguments)
1494 .expect("sanitized args must be valid JSON");
1495 assert_eq!(tc[0].arguments, "{}");
1496 } else {
1497 panic!("expected Assistant with tool_calls");
1498 }
1499 }
1500
1501 #[test]
1504 fn trim_oldest_turns_never_exceeds_max(turns in 1usize..20, max in 1usize..20) {
1505 let mut s = make_session();
1506 for i in 0..turns {
1507 s.push_message(MessageRole::User, format!("u{}", i));
1508 s.push_message(MessageRole::Assistant, format!("a{}", i));
1509 }
1510 s.trim_oldest_turns(max);
1511 assert!(s.turn_count() <= max || turns <= max);
1512 }
1513
1514 #[test]
1517 fn validate_simple_user_assistant_always_passes(count in 1usize..20) {
1518 let mut msgs = Vec::new();
1519 for i in 0..count {
1520 msgs.push(ChatMessage::user(format!("msg{}", i)));
1521 msgs.push(ChatMessage::assistant(format!("reply{}", i)));
1522 }
1523 assert!(validate_message_sequence(&msgs).is_ok());
1524 }
1525 }
1526}