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}
44
45impl RunState {
46 pub fn reset_for_new_run(&mut self) {
48 self.turn_tool_calls = 0;
49 self.run_has_tool_calls = false;
50 self.reasoning_only_strikes = 0;
51 self.empty_response_strikes = 0;
52 self.nudge_count = 0;
53 }
54
55 pub fn record_tool_calls(&mut self, n: usize) {
58 self.turn_tool_calls += n;
59 self.run_has_tool_calls = true;
60 self.reasoning_only_strikes = 0;
61 self.empty_response_strikes = 0;
62 }
63
64 pub fn record_reasoning_only(&mut self) -> usize {
68 self.empty_response_strikes = 0;
69 self.reasoning_only_strikes += 1;
70 self.reasoning_only_strikes
71 }
72
73 pub fn record_empty_response(&mut self) -> usize {
77 self.reasoning_only_strikes = 0;
78 self.empty_response_strikes += 1;
79 self.empty_response_strikes
80 }
81}
82
83#[derive(Deserialize)]
90struct RawAgentSession {
91 id: Option<SessionId>,
92 chat_messages: Vec<ChatMessage>,
93 always_allowed_actions: HashSet<String>,
94 total_tool_calls: usize,
95
96 run_state: Option<RunState>,
98
99 nudge_count: Option<usize>,
101 turn_tool_calls: Option<usize>,
102 reasoning_only_strikes: Option<usize>,
103 empty_response_strikes: Option<usize>,
104}
105
106impl From<RawAgentSession> for AgentSession {
107 fn from(raw: RawAgentSession) -> Self {
108 let run_state = raw.run_state.unwrap_or_else(|| RunState {
109 nudge_count: raw.nudge_count.unwrap_or(0),
110 turn_tool_calls: raw.turn_tool_calls.unwrap_or(0),
111 reasoning_only_strikes: raw.reasoning_only_strikes.unwrap_or(0),
112 empty_response_strikes: raw.empty_response_strikes.unwrap_or(0),
113 ..RunState::default()
114 });
115 Self {
116 id: raw.id,
117 chat_messages: raw.chat_messages,
118 always_allowed_actions: raw.always_allowed_actions,
119 total_tool_calls: raw.total_tool_calls,
120 run_state,
121 }
122 }
123}
124
125#[derive(Clone, Debug, Default, Serialize, Deserialize)]
130#[serde(from = "RawAgentSession")]
131pub struct AgentSession {
132 id: Option<SessionId>,
133 chat_messages: Vec<ChatMessage>,
136 always_allowed_actions: HashSet<String>,
137 pub total_tool_calls: usize,
140 pub run_state: RunState,
142}
143
144impl AgentSession {
145 pub fn new(id: SessionId) -> Self {
146 Self {
147 id: Some(id),
148 chat_messages: Vec::new(),
149 always_allowed_actions: HashSet::new(),
150 total_tool_calls: 0,
151 run_state: RunState::default(),
152 }
153 }
154
155 pub fn id(&self) -> Option<SessionId> {
156 self.id.clone()
157 }
158
159 pub fn simple_messages(&self) -> Vec<Message> {
163 self.chat_messages
164 .iter()
165 .filter_map(|cm| match cm {
166 ChatMessage::Assistant { content: None, .. } => None,
167 ChatMessage::Assistant {
168 content: Some(c),
169 tool_calls: Some(tc),
170 ..
171 } if c.is_empty() && !tc.is_empty() => None,
172 _ => Some(Message::from(cm)),
173 })
174 .collect()
175 }
176
177 pub fn chat_messages(&self) -> &[ChatMessage] {
178 &self.chat_messages
179 }
180
181 pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
183 &mut self.chat_messages
184 }
185
186 pub fn is_action_allowed(&self, action_key: &str) -> bool {
187 self.always_allowed_actions.contains(action_key)
188 }
189
190 pub fn allow_action(&mut self, action_key: impl Into<String>) {
191 self.always_allowed_actions.insert(action_key.into());
192 }
193
194 pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
195 let content = content.into();
196 let chat_msg = match role {
197 MessageRole::System => ChatMessage::system(content),
198 MessageRole::User => ChatMessage::user(content),
199 MessageRole::Assistant => ChatMessage::assistant(content),
200 MessageRole::Tool => ChatMessage::tool(String::new(), content),
201 };
202 self.chat_messages.push(chat_msg);
203 }
204
205 pub fn push_assistant_with_reasoning(
209 &mut self,
210 content: impl Into<String>,
211 reasoning: impl Into<String>,
212 ) {
213 self.chat_messages
214 .push(ChatMessage::assistant_with_reasoning(content, reasoning));
215 }
216
217 pub fn push_user_message_with_images(
218 &mut self,
219 content: impl Into<String>,
220 images: Vec<ImageAttachment>,
221 ) {
222 self.chat_messages
223 .push(ChatMessage::user_with_images(content, images));
224 }
225
226 pub fn push_assistant_tool_call(
227 &mut self,
228 tool_call_id: &str,
229 tool_name: &str,
230 arguments_json: &str,
231 ) {
232 self.chat_messages.push(ChatMessage::assistant_tool_call(
233 tool_call_id,
234 tool_name,
235 arguments_json,
236 ));
237 }
238
239 pub fn push_assistant_tool_calls(
240 &mut self,
241 tool_calls: &[(String, String, String)],
242 reasoning: Option<String>,
243 ) {
244 let calls: Vec<ToolCallMessage> = tool_calls
245 .iter()
246 .map(|(id, name, args)| ToolCallMessage {
247 id: id.clone(),
248 name: name.clone(),
249 arguments: args.clone(),
250 })
251 .collect();
252 self.chat_messages.push(ChatMessage::Assistant {
253 content: None,
254 reasoning_content: reasoning,
255 tool_calls: Some(calls),
256 });
257 }
258
259 pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
260 self.chat_messages
261 .push(ChatMessage::tool(tool_call_id, content));
262 }
263
264 pub fn remove_ephemeral_messages(&mut self) {
268 let before = self.chat_messages.len();
269 self.chat_messages.retain(|m| !m.is_ephemeral());
270 let removed = before - self.chat_messages.len();
271 if removed > 0 {
272 tracing::debug!(
273 removed,
274 remaining = self.chat_messages.len(),
275 "ephemeral messages cleaned up"
276 );
277 }
278 }
279
280 pub fn turn_count(&self) -> usize {
283 self.chat_messages
284 .iter()
285 .filter(|m| matches!(m, ChatMessage::User { .. }))
286 .count()
287 }
288
289 pub fn trim_oldest_turns(&mut self, max_turns: usize) {
292 let current_turns = self.turn_count();
293 if current_turns <= max_turns {
294 return;
295 }
296 let turns_to_remove = current_turns - max_turns;
297
298 let user_positions: Vec<usize> = self
300 .chat_messages
301 .iter()
302 .enumerate()
303 .filter_map(|(i, m)| {
304 if matches!(m, ChatMessage::User { .. }) {
305 Some(i)
306 } else {
307 None
308 }
309 })
310 .collect();
311
312 if user_positions.len() <= turns_to_remove {
313 return;
314 }
315
316 let system_prefix = self
318 .chat_messages
319 .iter()
320 .take_while(|m| matches!(m, ChatMessage::System { .. }))
321 .count();
322
323 let drain_end = user_positions[turns_to_remove];
325 if system_prefix >= drain_end {
326 return; }
328
329 self.chat_messages.drain(system_prefix..drain_end);
330 }
331
332 pub fn pop_last_message(&mut self) {
335 self.chat_messages.pop();
336 }
337
338 pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
339 let assistant_idx = self.chat_messages.iter().rposition(
340 |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
341 );
342
343 let Some(assistant_idx) = assistant_idx else {
344 return;
345 };
346
347 let ChatMessage::Assistant {
348 tool_calls: Some(tc),
349 ..
350 } = &self.chat_messages[assistant_idx]
351 else {
352 return;
353 };
354
355 let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
356
357 let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
358 .iter()
359 .filter_map(|m| match m {
360 ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
361 _ => None,
362 })
363 .collect();
364
365 for id in &all_ids {
366 if !answered_ids.iter().any(|a| a == id) {
367 self.push_tool_result(id, error_summary);
368 }
369 }
370 }
371
372 pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
377 validate_message_sequence(&messages)?;
378 self.total_tool_calls = messages
381 .iter()
382 .filter_map(|m| match m {
383 ChatMessage::Assistant {
384 tool_calls: Some(tc),
385 ..
386 } => Some(tc.len()),
387 _ => None,
388 })
389 .sum();
390 self.chat_messages = messages;
391 Ok(())
392 }
393}
394
395pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
403 let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
404
405 for (i, msg) in messages.iter().enumerate() {
406 match msg {
407 ChatMessage::Tool { tool_call_id, .. } => {
408 if pending_tool_call_ids.is_empty() {
409 return Err(format!(
410 "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
411 i, tool_call_id
412 ));
413 }
414 if !pending_tool_call_ids.remove(tool_call_id) {
416 return Err(format!(
417 "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
418 i, tool_call_id
419 ));
420 }
421 }
422 ChatMessage::Assistant {
423 tool_calls: Some(tc),
424 ..
425 } => {
426 if !pending_tool_call_ids.is_empty() {
428 return Err(format!(
429 "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
430 i, pending_tool_call_ids
431 ));
432 }
433 pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
434 }
435 _ => {}
436 }
437 }
438
439 if !pending_tool_call_ids.is_empty() {
441 return Err(format!(
442 "message sequence ends with unanswered tool calls: {:?}",
443 pending_tool_call_ids
444 ));
445 }
446
447 Ok(())
448}
449
450#[cfg(test)]
451fn make_session() -> AgentSession {
452 AgentSession::new(SessionId::new(1))
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_turn_count_empty() {
461 let s = make_session();
462 assert_eq!(s.turn_count(), 0);
463 }
464
465 #[test]
466 fn test_turn_count_with_system_and_user() {
467 let mut s = make_session();
468 s.push_message(MessageRole::System, "system");
469 assert_eq!(s.turn_count(), 0);
470 s.push_message(MessageRole::User, "hello");
471 assert_eq!(s.turn_count(), 1);
472 s.push_message(MessageRole::Assistant, "hi");
473 assert_eq!(s.turn_count(), 1);
474 s.push_message(MessageRole::User, "bye");
475 assert_eq!(s.turn_count(), 2);
476 }
477
478 #[test]
479 fn test_turn_count_with_tool_calls() {
480 let mut s = make_session();
481 s.push_message(MessageRole::User, "do something");
482 s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None);
483 s.push_tool_result("id1", "result");
484 s.push_message(MessageRole::Assistant, "done");
485 assert_eq!(s.turn_count(), 1);
487 }
488
489 #[test]
490 fn test_trim_oldest_turns_noop() {
491 let mut s = make_session();
492 s.push_message(MessageRole::User, "hello");
493 s.push_message(MessageRole::Assistant, "hi");
494 s.trim_oldest_turns(5);
495 assert_eq!(s.turn_count(), 1);
496 assert_eq!(s.chat_messages().len(), 2);
497 }
498
499 #[test]
500 fn test_trim_oldest_turns_removes_old() {
501 let mut s = make_session();
502 s.push_message(MessageRole::System, "sys");
503 s.push_message(MessageRole::User, "u1");
505 s.push_message(MessageRole::Assistant, "a1");
506 s.push_message(MessageRole::User, "u2");
508 s.push_message(MessageRole::Assistant, "a2");
509 s.push_message(MessageRole::User, "u3");
511 s.push_message(MessageRole::Assistant, "a3");
512
513 s.trim_oldest_turns(2);
514 assert_eq!(s.turn_count(), 2);
515 assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
517 assert!(
519 matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
520 );
521 }
522
523 #[test]
524 fn test_trim_oldest_turns_with_tool_calls() {
525 let mut s = make_session();
526 s.push_message(MessageRole::User, "u1");
528 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
529 s.push_tool_result("id1", "r1");
530 s.push_message(MessageRole::Assistant, "a1");
531 s.push_message(MessageRole::User, "u2");
533 s.push_message(MessageRole::Assistant, "a2");
534
535 let msg_before = s.simple_messages().len();
536 let chat_before = s.chat_messages().len();
537 s.trim_oldest_turns(1);
538 assert_eq!(s.turn_count(), 1);
539 assert_eq!(s.chat_messages().len(), chat_before - 4);
541 assert_eq!(s.simple_messages().len(), msg_before - 3);
543 }
544
545 #[test]
546 fn test_pop_last_message_text() {
547 let mut s = make_session();
548 s.push_message(MessageRole::User, "hello");
549 s.push_message(MessageRole::Assistant, "hi");
550 assert_eq!(s.chat_messages().len(), 2);
551 s.pop_last_message();
552 assert_eq!(s.chat_messages().len(), 1);
553 assert_eq!(s.simple_messages().len(), 1);
554 }
555
556 #[test]
557 fn test_pop_last_message_tool_calls_only() {
558 let mut s = make_session();
559 s.push_message(MessageRole::User, "do it");
560 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
561 assert_eq!(s.chat_messages().len(), 2);
562 assert_eq!(s.simple_messages().len(), 1); s.pop_last_message();
564 assert_eq!(s.chat_messages().len(), 1);
565 assert_eq!(s.simple_messages().len(), 1); }
567
568 #[test]
569 fn test_pop_last_message_empty_session() {
570 let mut s = make_session();
571 s.pop_last_message(); assert_eq!(s.chat_messages().len(), 0);
573 }
574
575 #[test]
578 fn test_id_and_action_allowlist() {
579 let mut s = make_session();
580 assert_eq!(s.id(), Some(SessionId::new(1)));
581 assert!(!s.is_action_allowed("approve:rm"));
582 s.allow_action("approve:rm");
583 assert!(s.is_action_allowed("approve:rm"));
584 assert!(!s.is_action_allowed("approve:shell"));
585 }
586
587 #[test]
588 fn test_chat_messages_mut() {
589 let mut s = make_session();
590 s.chat_messages_mut().push(ChatMessage::user("direct"));
591 assert_eq!(s.chat_messages().len(), 1);
592 }
593
594 #[test]
595 fn test_push_message_tool_role() {
596 let mut s = make_session();
597 s.push_message(MessageRole::Tool, "result");
598 assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
599 }
600
601 #[test]
602 fn test_push_assistant_with_reasoning() {
603 let mut s = make_session();
604 s.push_assistant_with_reasoning("answer", "thinking");
605 match &s.chat_messages()[0] {
606 ChatMessage::Assistant {
607 content,
608 reasoning_content,
609 ..
610 } => {
611 assert_eq!(content.as_deref(), Some("answer"));
612 assert_eq!(reasoning_content.as_deref(), Some("thinking"));
613 }
614 other => panic!("unexpected message: {other:?}"),
615 }
616 }
617
618 #[test]
619 fn test_push_user_message_with_images() {
620 let mut s = make_session();
621 s.push_user_message_with_images(
622 "look",
623 vec![ImageAttachment::Url {
624 url: "http://x".into(),
625 detail: None,
626 }],
627 );
628 match &s.chat_messages()[0] {
629 ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
630 other => panic!("unexpected message: {other:?}"),
631 }
632 }
633
634 #[test]
635 fn test_push_assistant_tool_call_singular() {
636 let mut s = make_session();
637 s.push_assistant_tool_call("call_1", "bash", "{}");
638 match &s.chat_messages()[0] {
639 ChatMessage::Assistant {
640 tool_calls: Some(tc),
641 ..
642 } => {
643 assert_eq!(tc.len(), 1);
644 assert_eq!(tc[0].id, "call_1");
645 assert_eq!(tc[0].name, "bash");
646 }
647 other => panic!("unexpected message: {other:?}"),
648 }
649 }
650
651 #[test]
652 fn test_simple_messages_filters_empty_content_tool_calls() {
653 let mut s = make_session();
654 s.chat_messages_mut().push(ChatMessage::Assistant {
655 content: Some(String::new()),
656 reasoning_content: None,
657 tool_calls: Some(vec![ToolCallMessage {
658 id: "c".into(),
659 name: "t".into(),
660 arguments: "{}".into(),
661 }]),
662 });
663 assert!(s.simple_messages().is_empty());
664 }
665
666 #[test]
667 fn test_remove_ephemeral_messages() {
668 let mut s = make_session();
669 s.push_message(MessageRole::System, "keep");
670 s.chat_messages_mut()
671 .push(ChatMessage::user_ephemeral("temp"));
672 s.chat_messages_mut()
673 .push(ChatMessage::system_ephemeral("temp2"));
674 s.push_message(MessageRole::User, "keep2");
675 assert_eq!(s.chat_messages().len(), 4);
676 s.remove_ephemeral_messages();
677 assert_eq!(s.chat_messages().len(), 2);
678 assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
679 }
680
681 #[test]
682 fn test_close_dangling_tool_calls_noop_without_tool_call() {
683 let mut s = make_session();
684 s.push_message(MessageRole::User, "hi");
685 s.push_message(MessageRole::Assistant, "hi");
686 s.close_dangling_tool_calls("failed");
687 assert_eq!(s.chat_messages().len(), 2);
688 }
689
690 #[test]
691 fn test_close_dangling_tool_calls_adds_missing_results() {
692 let mut s = make_session();
693 s.push_message(MessageRole::User, "do");
694 s.push_assistant_tool_calls(
695 &[
696 ("c1".into(), "t".into(), "{}".into()),
697 ("c2".into(), "t".into(), "{}".into()),
698 ],
699 None,
700 );
701 s.push_tool_result("c1", "ok"); s.close_dangling_tool_calls("failed");
703
704 let tool_results: Vec<(String, String)> = s
705 .chat_messages()
706 .iter()
707 .filter_map(|m| match m {
708 ChatMessage::Tool {
709 tool_call_id,
710 content,
711 } => Some((tool_call_id.clone(), content.clone())),
712 _ => None,
713 })
714 .collect();
715 assert_eq!(tool_results.len(), 2);
716 assert!(
717 tool_results
718 .iter()
719 .any(|(id, c)| id == "c2" && c == "failed")
720 );
721 }
722
723 #[test]
724 fn test_set_chat_messages_recalculates_total_tool_calls() {
725 let mut s = make_session();
726 let msgs = vec![
727 ChatMessage::user("do"),
728 ChatMessage::assistant_tool_call("c1", "t", "{}"),
729 ChatMessage::tool("c1", "result"),
730 ];
731 s.set_chat_messages(msgs).unwrap();
732 assert_eq!(s.total_tool_calls, 1);
733 }
734}
735
736#[cfg(test)]
737mod validate_tests {
738 use super::*;
739
740 #[test]
741 fn test_valid_simple_sequence() {
742 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
743 assert!(validate_message_sequence(&msgs).is_ok());
744 }
745
746 #[test]
747 fn test_valid_tool_call_sequence() {
748 let msgs = vec![
749 ChatMessage::user("run command"),
750 ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
751 ChatMessage::tool("call_1", "file1 file2"),
752 ChatMessage::assistant("done"),
753 ];
754 assert!(validate_message_sequence(&msgs).is_ok());
755 }
756
757 #[test]
758 fn test_valid_multi_tool_call_sequence() {
759 let msgs = vec![
760 ChatMessage::user("run commands"),
761 ChatMessage::Assistant {
762 content: None,
763 reasoning_content: None,
764 tool_calls: Some(vec![
765 crate::types::ToolCallMessage {
766 id: "call_1".into(),
767 name: "bash".into(),
768 arguments: "{}".into(),
769 },
770 crate::types::ToolCallMessage {
771 id: "call_2".into(),
772 name: "read".into(),
773 arguments: "{}".into(),
774 },
775 ]),
776 },
777 ChatMessage::tool("call_1", "result1"),
778 ChatMessage::tool("call_2", "result2"),
779 ChatMessage::assistant("done"),
780 ];
781 assert!(validate_message_sequence(&msgs).is_ok());
782 }
783
784 #[test]
785 fn test_orphaned_tool_result() {
786 let msgs = vec![
787 ChatMessage::user("hello"),
788 ChatMessage::tool("call_1", "orphaned result"),
789 ];
790 let err = validate_message_sequence(&msgs).unwrap_err();
791 assert!(err.contains("no preceding tool_call"));
792 }
793
794 #[test]
795 fn test_mismatched_tool_call_id() {
796 let msgs = vec![
797 ChatMessage::user("run"),
798 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
799 ChatMessage::tool("call_2", "wrong id"),
800 ];
801 let err = validate_message_sequence(&msgs).unwrap_err();
802 assert!(err.contains("does not match"));
803 }
804
805 #[test]
806 fn test_set_chat_messages_valid() {
807 let mut s = make_session();
808 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
809 assert!(s.set_chat_messages(msgs.clone()).is_ok());
810 assert_eq!(s.chat_messages().len(), 2);
811 }
812
813 #[test]
814 fn test_set_chat_messages_invalid() {
815 let mut s = make_session();
816 let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
817 assert!(s.set_chat_messages(msgs).is_err());
818 }
819
820 #[test]
823 fn run_state_default() {
824 let rs = RunState::default();
825 assert_eq!(rs.turn_tool_calls, 0);
826 assert!(!rs.run_has_tool_calls);
827 assert_eq!(rs.reasoning_only_strikes, 0);
828 assert_eq!(rs.empty_response_strikes, 0);
829 assert_eq!(rs.nudge_count, 0);
830 }
831
832 #[test]
833 fn run_state_reset_for_new_run() {
834 let mut rs = RunState {
835 turn_tool_calls: 5,
836 run_has_tool_calls: true,
837 reasoning_only_strikes: 2,
838 empty_response_strikes: 1,
839 nudge_count: 3,
840 };
841
842 rs.reset_for_new_run();
843
844 assert_eq!(rs.turn_tool_calls, 0);
845 assert!(!rs.run_has_tool_calls);
846 assert_eq!(rs.reasoning_only_strikes, 0);
847 assert_eq!(rs.empty_response_strikes, 0);
848 assert_eq!(rs.nudge_count, 0);
849 }
850
851 #[test]
852 fn run_state_record_tool_calls() {
853 let mut rs = RunState {
854 reasoning_only_strikes: 2,
855 empty_response_strikes: 1,
856 ..RunState::default()
857 };
858
859 rs.record_tool_calls(3);
860
861 assert_eq!(rs.turn_tool_calls, 3);
862 assert!(rs.run_has_tool_calls);
863 assert_eq!(rs.reasoning_only_strikes, 0); assert_eq!(rs.empty_response_strikes, 0); }
866
867 #[test]
868 fn run_state_record_tool_calls_accumulates() {
869 let mut rs = RunState::default();
870 rs.record_tool_calls(2);
871 rs.record_tool_calls(3);
872
873 assert_eq!(rs.turn_tool_calls, 5);
874 assert!(rs.run_has_tool_calls);
875 }
876
877 #[test]
878 fn run_state_record_reasoning_only() {
879 let mut rs = RunState {
880 empty_response_strikes: 2,
881 ..RunState::default()
882 };
883
884 let strikes = rs.record_reasoning_only();
885
886 assert_eq!(strikes, 1);
887 assert_eq!(rs.reasoning_only_strikes, 1);
888 assert_eq!(rs.empty_response_strikes, 0); }
890
891 #[test]
892 fn run_state_record_reasoning_only_consecutive() {
893 let mut rs = RunState::default();
894
895 assert_eq!(rs.record_reasoning_only(), 1);
896 assert_eq!(rs.record_reasoning_only(), 2);
897 assert_eq!(rs.record_reasoning_only(), 3);
898 }
899
900 #[test]
901 fn run_state_record_empty_response() {
902 let mut rs = RunState {
903 reasoning_only_strikes: 2,
904 ..RunState::default()
905 };
906
907 let strikes = rs.record_empty_response();
908
909 assert_eq!(strikes, 1);
910 assert_eq!(rs.empty_response_strikes, 1);
911 assert_eq!(rs.reasoning_only_strikes, 0); }
913
914 #[test]
915 fn run_state_record_empty_response_consecutive() {
916 let mut rs = RunState::default();
917
918 assert_eq!(rs.record_empty_response(), 1);
919 assert_eq!(rs.record_empty_response(), 2);
920 assert_eq!(rs.record_empty_response(), 3);
921 }
922
923 #[test]
924 fn run_state_branch_cross_reset() {
925 let mut rs = RunState::default();
927
928 rs.record_reasoning_only();
930 assert_eq!(rs.reasoning_only_strikes, 1);
931
932 rs.record_tool_calls(2);
934 assert_eq!(rs.reasoning_only_strikes, 0);
935 assert_eq!(rs.turn_tool_calls, 2);
936
937 let strikes = rs.record_reasoning_only();
939 assert_eq!(strikes, 1);
940 }
941
942 #[test]
943 fn run_state_empty_to_reasoning_reset() {
944 let mut rs = RunState::default();
946
947 rs.record_empty_response();
948 rs.record_empty_response();
949 assert_eq!(rs.empty_response_strikes, 2);
950
951 rs.record_reasoning_only();
953 assert_eq!(rs.empty_response_strikes, 0);
954 assert_eq!(rs.reasoning_only_strikes, 1);
955 }
956
957 #[test]
960 fn deserialize_legacy_flat_fields() {
961 let json = r#"{
963 "id": null,
964 "chat_messages": [],
965 "always_allowed_actions": [],
966 "total_tool_calls": 5,
967 "nudge_count": 3,
968 "turn_tool_calls": 2,
969 "reasoning_only_strikes": 1,
970 "empty_response_strikes": 0
971 }"#;
972 let session: AgentSession = serde_json::from_str(json).unwrap();
973 assert_eq!(session.run_state.nudge_count, 3);
974 assert_eq!(session.run_state.turn_tool_calls, 2);
975 assert_eq!(session.run_state.reasoning_only_strikes, 1);
976 assert_eq!(session.run_state.empty_response_strikes, 0);
977 assert!(!session.run_state.run_has_tool_calls); }
979
980 #[test]
981 fn deserialize_new_run_state_format() {
982 let json = r#"{
984 "id": null,
985 "chat_messages": [],
986 "always_allowed_actions": [],
987 "total_tool_calls": 5,
988 "run_state": {
989 "turn_tool_calls": 4,
990 "run_has_tool_calls": true,
991 "reasoning_only_strikes": 0,
992 "empty_response_strikes": 1,
993 "nudge_count": 2
994 }
995 }"#;
996 let session: AgentSession = serde_json::from_str(json).unwrap();
997 assert_eq!(session.run_state.turn_tool_calls, 4);
998 assert!(session.run_state.run_has_tool_calls);
999 assert_eq!(session.run_state.empty_response_strikes, 1);
1000 assert_eq!(session.run_state.nudge_count, 2);
1001 }
1002
1003 #[test]
1004 fn deserialize_run_state_takes_precedence_over_flat() {
1005 let json = r#"{
1007 "id": null,
1008 "chat_messages": [],
1009 "always_allowed_actions": [],
1010 "total_tool_calls": 0,
1011 "run_state": {
1012 "turn_tool_calls": 10,
1013 "run_has_tool_calls": true,
1014 "reasoning_only_strikes": 0,
1015 "empty_response_strikes": 0,
1016 "nudge_count": 0
1017 },
1018 "nudge_count": 99,
1019 "turn_tool_calls": 99
1020 }"#;
1021 let session: AgentSession = serde_json::from_str(json).unwrap();
1022 assert_eq!(session.run_state.turn_tool_calls, 10); assert_eq!(session.run_state.nudge_count, 0); }
1025
1026 #[test]
1027 fn deserialize_legacy_missing_optional_fields() {
1028 let json = r#"{
1030 "id": null,
1031 "chat_messages": [],
1032 "always_allowed_actions": [],
1033 "total_tool_calls": 0,
1034 "nudge_count": 1
1035 }"#;
1036 let session: AgentSession = serde_json::from_str(json).unwrap();
1037 assert_eq!(session.run_state.nudge_count, 1);
1038 assert_eq!(session.run_state.turn_tool_calls, 0); assert_eq!(session.run_state.reasoning_only_strikes, 0);
1040 assert_eq!(session.run_state.empty_response_strikes, 0);
1041 }
1042
1043 #[test]
1044 fn roundtrip_preserves_run_state() {
1045 let mut session = AgentSession::new(SessionId::new(1));
1046 session.run_state.nudge_count = 5;
1047 session.run_state.turn_tool_calls = 3;
1048 session.run_state.run_has_tool_calls = true;
1049 session.run_state.reasoning_only_strikes = 2;
1050
1051 let json = serde_json::to_string(&session).unwrap();
1052 let restored: AgentSession = serde_json::from_str(&json).unwrap();
1053 assert_eq!(restored.run_state.nudge_count, 5);
1054 assert_eq!(restored.run_state.turn_tool_calls, 3);
1055 assert!(restored.run_state.run_has_tool_calls);
1056 assert_eq!(restored.run_state.reasoning_only_strikes, 2);
1057 }
1058}