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)]
10pub struct AgentSession {
11 id: Option<SessionId>,
12 chat_messages: Vec<ChatMessage>,
15 always_allowed_actions: HashSet<String>,
16 pub total_tool_calls: usize,
19 pub nudge_count: usize,
23 pub turn_tool_calls: usize,
27}
28
29impl AgentSession {
30 pub fn new(id: SessionId) -> Self {
31 Self {
32 id: Some(id),
33 chat_messages: Vec::new(),
34 always_allowed_actions: HashSet::new(),
35 total_tool_calls: 0,
36 nudge_count: 0,
37 turn_tool_calls: 0,
38 }
39 }
40
41 pub fn id(&self) -> Option<SessionId> {
42 self.id.clone()
43 }
44
45 pub fn simple_messages(&self) -> Vec<Message> {
49 self.chat_messages
50 .iter()
51 .filter_map(|cm| match cm {
52 ChatMessage::Assistant { content: None, .. } => None,
53 ChatMessage::Assistant {
54 content: Some(c),
55 tool_calls: Some(tc),
56 ..
57 } if c.is_empty() && !tc.is_empty() => None,
58 _ => Some(Message::from(cm)),
59 })
60 .collect()
61 }
62
63 pub fn chat_messages(&self) -> &[ChatMessage] {
64 &self.chat_messages
65 }
66
67 pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
69 &mut self.chat_messages
70 }
71
72 pub fn is_action_allowed(&self, action_key: &str) -> bool {
73 self.always_allowed_actions.contains(action_key)
74 }
75
76 pub fn allow_action(&mut self, action_key: impl Into<String>) {
77 self.always_allowed_actions.insert(action_key.into());
78 }
79
80 pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
81 let content = content.into();
82 let chat_msg = match role {
83 MessageRole::System => ChatMessage::system(content),
84 MessageRole::User => ChatMessage::user(content),
85 MessageRole::Assistant => ChatMessage::assistant(content),
86 MessageRole::Tool => ChatMessage::tool(String::new(), content),
87 };
88 self.chat_messages.push(chat_msg);
89 }
90
91 pub fn push_assistant_with_reasoning(
95 &mut self,
96 content: impl Into<String>,
97 reasoning: impl Into<String>,
98 ) {
99 self.chat_messages
100 .push(ChatMessage::assistant_with_reasoning(content, reasoning));
101 }
102
103 pub fn push_user_message_with_images(
104 &mut self,
105 content: impl Into<String>,
106 images: Vec<ImageAttachment>,
107 ) {
108 self.chat_messages
109 .push(ChatMessage::user_with_images(content, images));
110 }
111
112 pub fn push_assistant_tool_call(
113 &mut self,
114 tool_call_id: &str,
115 tool_name: &str,
116 arguments_json: &str,
117 ) {
118 self.chat_messages.push(ChatMessage::assistant_tool_call(
119 tool_call_id,
120 tool_name,
121 arguments_json,
122 ));
123 }
124
125 pub fn push_assistant_tool_calls(
126 &mut self,
127 tool_calls: &[(String, String, String)],
128 reasoning: Option<String>,
129 ) {
130 let calls: Vec<ToolCallMessage> = tool_calls
131 .iter()
132 .map(|(id, name, args)| ToolCallMessage {
133 id: id.clone(),
134 name: name.clone(),
135 arguments: args.clone(),
136 })
137 .collect();
138 self.chat_messages.push(ChatMessage::Assistant {
139 content: None,
140 reasoning_content: reasoning,
141 tool_calls: Some(calls),
142 });
143 }
144
145 pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
146 self.chat_messages
147 .push(ChatMessage::tool(tool_call_id, content));
148 }
149
150 pub fn remove_ephemeral_messages(&mut self) {
154 let before = self.chat_messages.len();
155 self.chat_messages.retain(|m| !m.is_ephemeral());
156 let removed = before - self.chat_messages.len();
157 if removed > 0 {
158 tracing::debug!(
159 removed,
160 remaining = self.chat_messages.len(),
161 "ephemeral messages cleaned up"
162 );
163 }
164 }
165
166 pub fn turn_count(&self) -> usize {
169 self.chat_messages
170 .iter()
171 .filter(|m| matches!(m, ChatMessage::User { .. }))
172 .count()
173 }
174
175 pub fn trim_oldest_turns(&mut self, max_turns: usize) {
178 let current_turns = self.turn_count();
179 if current_turns <= max_turns {
180 return;
181 }
182 let turns_to_remove = current_turns - max_turns;
183
184 let user_positions: Vec<usize> = self
186 .chat_messages
187 .iter()
188 .enumerate()
189 .filter_map(|(i, m)| {
190 if matches!(m, ChatMessage::User { .. }) {
191 Some(i)
192 } else {
193 None
194 }
195 })
196 .collect();
197
198 if user_positions.len() <= turns_to_remove {
199 return;
200 }
201
202 let system_prefix = self
204 .chat_messages
205 .iter()
206 .take_while(|m| matches!(m, ChatMessage::System { .. }))
207 .count();
208
209 let drain_end = user_positions[turns_to_remove];
211 if system_prefix >= drain_end {
212 return; }
214
215 self.chat_messages.drain(system_prefix..drain_end);
216 }
217
218 pub fn pop_last_message(&mut self) {
221 self.chat_messages.pop();
222 }
223
224 pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
225 let assistant_idx = self.chat_messages.iter().rposition(
226 |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
227 );
228
229 let Some(assistant_idx) = assistant_idx else {
230 return;
231 };
232
233 let ChatMessage::Assistant {
234 tool_calls: Some(tc),
235 ..
236 } = &self.chat_messages[assistant_idx]
237 else {
238 return;
239 };
240
241 let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
242
243 let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
244 .iter()
245 .filter_map(|m| match m {
246 ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
247 _ => None,
248 })
249 .collect();
250
251 for id in &all_ids {
252 if !answered_ids.iter().any(|a| a == id) {
253 self.push_tool_result(id, error_summary);
254 }
255 }
256 }
257
258 pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
263 validate_message_sequence(&messages)?;
264 self.total_tool_calls = messages
267 .iter()
268 .filter_map(|m| match m {
269 ChatMessage::Assistant {
270 tool_calls: Some(tc),
271 ..
272 } => Some(tc.len()),
273 _ => None,
274 })
275 .sum();
276 self.chat_messages = messages;
277 Ok(())
278 }
279}
280
281pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
289 let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
290
291 for (i, msg) in messages.iter().enumerate() {
292 match msg {
293 ChatMessage::Tool { tool_call_id, .. } => {
294 if pending_tool_call_ids.is_empty() {
295 return Err(format!(
296 "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
297 i, tool_call_id
298 ));
299 }
300 if !pending_tool_call_ids.remove(tool_call_id) {
302 return Err(format!(
303 "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
304 i, tool_call_id
305 ));
306 }
307 }
308 ChatMessage::Assistant {
309 tool_calls: Some(tc),
310 ..
311 } => {
312 if !pending_tool_call_ids.is_empty() {
314 return Err(format!(
315 "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
316 i, pending_tool_call_ids
317 ));
318 }
319 pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
320 }
321 _ => {}
322 }
323 }
324
325 if !pending_tool_call_ids.is_empty() {
327 return Err(format!(
328 "message sequence ends with unanswered tool calls: {:?}",
329 pending_tool_call_ids
330 ));
331 }
332
333 Ok(())
334}
335
336#[cfg(test)]
337fn make_session() -> AgentSession {
338 AgentSession::new(SessionId::new(1))
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_turn_count_empty() {
347 let s = make_session();
348 assert_eq!(s.turn_count(), 0);
349 }
350
351 #[test]
352 fn test_turn_count_with_system_and_user() {
353 let mut s = make_session();
354 s.push_message(MessageRole::System, "system");
355 assert_eq!(s.turn_count(), 0);
356 s.push_message(MessageRole::User, "hello");
357 assert_eq!(s.turn_count(), 1);
358 s.push_message(MessageRole::Assistant, "hi");
359 assert_eq!(s.turn_count(), 1);
360 s.push_message(MessageRole::User, "bye");
361 assert_eq!(s.turn_count(), 2);
362 }
363
364 #[test]
365 fn test_turn_count_with_tool_calls() {
366 let mut s = make_session();
367 s.push_message(MessageRole::User, "do something");
368 s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None);
369 s.push_tool_result("id1", "result");
370 s.push_message(MessageRole::Assistant, "done");
371 assert_eq!(s.turn_count(), 1);
373 }
374
375 #[test]
376 fn test_trim_oldest_turns_noop() {
377 let mut s = make_session();
378 s.push_message(MessageRole::User, "hello");
379 s.push_message(MessageRole::Assistant, "hi");
380 s.trim_oldest_turns(5);
381 assert_eq!(s.turn_count(), 1);
382 assert_eq!(s.chat_messages().len(), 2);
383 }
384
385 #[test]
386 fn test_trim_oldest_turns_removes_old() {
387 let mut s = make_session();
388 s.push_message(MessageRole::System, "sys");
389 s.push_message(MessageRole::User, "u1");
391 s.push_message(MessageRole::Assistant, "a1");
392 s.push_message(MessageRole::User, "u2");
394 s.push_message(MessageRole::Assistant, "a2");
395 s.push_message(MessageRole::User, "u3");
397 s.push_message(MessageRole::Assistant, "a3");
398
399 s.trim_oldest_turns(2);
400 assert_eq!(s.turn_count(), 2);
401 assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
403 assert!(
405 matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
406 );
407 }
408
409 #[test]
410 fn test_trim_oldest_turns_with_tool_calls() {
411 let mut s = make_session();
412 s.push_message(MessageRole::User, "u1");
414 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
415 s.push_tool_result("id1", "r1");
416 s.push_message(MessageRole::Assistant, "a1");
417 s.push_message(MessageRole::User, "u2");
419 s.push_message(MessageRole::Assistant, "a2");
420
421 let msg_before = s.simple_messages().len();
422 let chat_before = s.chat_messages().len();
423 s.trim_oldest_turns(1);
424 assert_eq!(s.turn_count(), 1);
425 assert_eq!(s.chat_messages().len(), chat_before - 4);
427 assert_eq!(s.simple_messages().len(), msg_before - 3);
429 }
430
431 #[test]
432 fn test_pop_last_message_text() {
433 let mut s = make_session();
434 s.push_message(MessageRole::User, "hello");
435 s.push_message(MessageRole::Assistant, "hi");
436 assert_eq!(s.chat_messages().len(), 2);
437 s.pop_last_message();
438 assert_eq!(s.chat_messages().len(), 1);
439 assert_eq!(s.simple_messages().len(), 1);
440 }
441
442 #[test]
443 fn test_pop_last_message_tool_calls_only() {
444 let mut s = make_session();
445 s.push_message(MessageRole::User, "do it");
446 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
447 assert_eq!(s.chat_messages().len(), 2);
448 assert_eq!(s.simple_messages().len(), 1); s.pop_last_message();
450 assert_eq!(s.chat_messages().len(), 1);
451 assert_eq!(s.simple_messages().len(), 1); }
453
454 #[test]
455 fn test_pop_last_message_empty_session() {
456 let mut s = make_session();
457 s.pop_last_message(); assert_eq!(s.chat_messages().len(), 0);
459 }
460
461 #[test]
464 fn test_id_and_action_allowlist() {
465 let mut s = make_session();
466 assert_eq!(s.id(), Some(SessionId::new(1)));
467 assert!(!s.is_action_allowed("approve:rm"));
468 s.allow_action("approve:rm");
469 assert!(s.is_action_allowed("approve:rm"));
470 assert!(!s.is_action_allowed("approve:shell"));
471 }
472
473 #[test]
474 fn test_chat_messages_mut() {
475 let mut s = make_session();
476 s.chat_messages_mut().push(ChatMessage::user("direct"));
477 assert_eq!(s.chat_messages().len(), 1);
478 }
479
480 #[test]
481 fn test_push_message_tool_role() {
482 let mut s = make_session();
483 s.push_message(MessageRole::Tool, "result");
484 assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
485 }
486
487 #[test]
488 fn test_push_assistant_with_reasoning() {
489 let mut s = make_session();
490 s.push_assistant_with_reasoning("answer", "thinking");
491 match &s.chat_messages()[0] {
492 ChatMessage::Assistant {
493 content,
494 reasoning_content,
495 ..
496 } => {
497 assert_eq!(content.as_deref(), Some("answer"));
498 assert_eq!(reasoning_content.as_deref(), Some("thinking"));
499 }
500 other => panic!("unexpected message: {other:?}"),
501 }
502 }
503
504 #[test]
505 fn test_push_user_message_with_images() {
506 let mut s = make_session();
507 s.push_user_message_with_images(
508 "look",
509 vec![ImageAttachment::Url {
510 url: "http://x".into(),
511 detail: None,
512 }],
513 );
514 match &s.chat_messages()[0] {
515 ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
516 other => panic!("unexpected message: {other:?}"),
517 }
518 }
519
520 #[test]
521 fn test_push_assistant_tool_call_singular() {
522 let mut s = make_session();
523 s.push_assistant_tool_call("call_1", "bash", "{}");
524 match &s.chat_messages()[0] {
525 ChatMessage::Assistant {
526 tool_calls: Some(tc),
527 ..
528 } => {
529 assert_eq!(tc.len(), 1);
530 assert_eq!(tc[0].id, "call_1");
531 assert_eq!(tc[0].name, "bash");
532 }
533 other => panic!("unexpected message: {other:?}"),
534 }
535 }
536
537 #[test]
538 fn test_simple_messages_filters_empty_content_tool_calls() {
539 let mut s = make_session();
540 s.chat_messages_mut().push(ChatMessage::Assistant {
541 content: Some(String::new()),
542 reasoning_content: None,
543 tool_calls: Some(vec![ToolCallMessage {
544 id: "c".into(),
545 name: "t".into(),
546 arguments: "{}".into(),
547 }]),
548 });
549 assert!(s.simple_messages().is_empty());
550 }
551
552 #[test]
553 fn test_remove_ephemeral_messages() {
554 let mut s = make_session();
555 s.push_message(MessageRole::System, "keep");
556 s.chat_messages_mut()
557 .push(ChatMessage::user_ephemeral("temp"));
558 s.chat_messages_mut()
559 .push(ChatMessage::system_ephemeral("temp2"));
560 s.push_message(MessageRole::User, "keep2");
561 assert_eq!(s.chat_messages().len(), 4);
562 s.remove_ephemeral_messages();
563 assert_eq!(s.chat_messages().len(), 2);
564 assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
565 }
566
567 #[test]
568 fn test_close_dangling_tool_calls_noop_without_tool_call() {
569 let mut s = make_session();
570 s.push_message(MessageRole::User, "hi");
571 s.push_message(MessageRole::Assistant, "hi");
572 s.close_dangling_tool_calls("failed");
573 assert_eq!(s.chat_messages().len(), 2);
574 }
575
576 #[test]
577 fn test_close_dangling_tool_calls_adds_missing_results() {
578 let mut s = make_session();
579 s.push_message(MessageRole::User, "do");
580 s.push_assistant_tool_calls(
581 &[
582 ("c1".into(), "t".into(), "{}".into()),
583 ("c2".into(), "t".into(), "{}".into()),
584 ],
585 None,
586 );
587 s.push_tool_result("c1", "ok"); s.close_dangling_tool_calls("failed");
589
590 let tool_results: Vec<(String, String)> = s
591 .chat_messages()
592 .iter()
593 .filter_map(|m| match m {
594 ChatMessage::Tool {
595 tool_call_id,
596 content,
597 } => Some((tool_call_id.clone(), content.clone())),
598 _ => None,
599 })
600 .collect();
601 assert_eq!(tool_results.len(), 2);
602 assert!(
603 tool_results
604 .iter()
605 .any(|(id, c)| id == "c2" && c == "failed")
606 );
607 }
608
609 #[test]
610 fn test_set_chat_messages_recalculates_total_tool_calls() {
611 let mut s = make_session();
612 let msgs = vec![
613 ChatMessage::user("do"),
614 ChatMessage::assistant_tool_call("c1", "t", "{}"),
615 ChatMessage::tool("c1", "result"),
616 ];
617 s.set_chat_messages(msgs).unwrap();
618 assert_eq!(s.total_tool_calls, 1);
619 }
620}
621
622#[cfg(test)]
623mod validate_tests {
624 use super::*;
625
626 #[test]
627 fn test_valid_simple_sequence() {
628 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
629 assert!(validate_message_sequence(&msgs).is_ok());
630 }
631
632 #[test]
633 fn test_valid_tool_call_sequence() {
634 let msgs = vec![
635 ChatMessage::user("run command"),
636 ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
637 ChatMessage::tool("call_1", "file1 file2"),
638 ChatMessage::assistant("done"),
639 ];
640 assert!(validate_message_sequence(&msgs).is_ok());
641 }
642
643 #[test]
644 fn test_valid_multi_tool_call_sequence() {
645 let msgs = vec![
646 ChatMessage::user("run commands"),
647 ChatMessage::Assistant {
648 content: None,
649 reasoning_content: None,
650 tool_calls: Some(vec![
651 crate::types::ToolCallMessage {
652 id: "call_1".into(),
653 name: "bash".into(),
654 arguments: "{}".into(),
655 },
656 crate::types::ToolCallMessage {
657 id: "call_2".into(),
658 name: "read".into(),
659 arguments: "{}".into(),
660 },
661 ]),
662 },
663 ChatMessage::tool("call_1", "result1"),
664 ChatMessage::tool("call_2", "result2"),
665 ChatMessage::assistant("done"),
666 ];
667 assert!(validate_message_sequence(&msgs).is_ok());
668 }
669
670 #[test]
671 fn test_orphaned_tool_result() {
672 let msgs = vec![
673 ChatMessage::user("hello"),
674 ChatMessage::tool("call_1", "orphaned result"),
675 ];
676 let err = validate_message_sequence(&msgs).unwrap_err();
677 assert!(err.contains("no preceding tool_call"));
678 }
679
680 #[test]
681 fn test_mismatched_tool_call_id() {
682 let msgs = vec![
683 ChatMessage::user("run"),
684 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
685 ChatMessage::tool("call_2", "wrong id"),
686 ];
687 let err = validate_message_sequence(&msgs).unwrap_err();
688 assert!(err.contains("does not match"));
689 }
690
691 #[test]
692 fn test_set_chat_messages_valid() {
693 let mut s = make_session();
694 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
695 assert!(s.set_chat_messages(msgs.clone()).is_ok());
696 assert_eq!(s.chat_messages().len(), 2);
697 }
698
699 #[test]
700 fn test_set_chat_messages_invalid() {
701 let mut s = make_session();
702 let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
703 assert!(s.set_chat_messages(msgs).is_err());
704 }
705}