Skip to main content

agent_base/engine/
session.rs

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    /// LLM API format messages, sent directly to the provider.
13    /// This is the single source of truth for the conversation state.
14    chat_messages: Vec<ChatMessage>,
15    always_allowed_actions: HashSet<String>,
16    /// Total number of tool calls made in this session (across all turns).
17    /// Used by middleware for decisions like "first_turn_only" enforcement.
18    pub total_tool_calls: usize,
19    /// Number of tool-enforcement nudges issued in the current turn.
20    /// Reset to 0 at the start of each turn (when a new user message arrives).
21    /// Used by `ToolEnforcementMiddleware` to cap nudge attempts per turn.
22    pub nudge_count: usize,
23    /// Number of tool calls already executed in the current turn.
24    /// Reset to 0 at the start of each turn (when a new user message arrives).
25    /// Used by `TurnToolLimitMiddleware` to enforce per-turn tool call limits.
26    pub turn_tool_calls: usize,
27    /// Number of consecutive LLM turns that produced only reasoning_content
28    /// (no text, no tool call). Reset to 0 at the start of each turn. Used by
29    /// the react loop to fail instead of looping forever on a reasoning-model
30    /// runaway.
31    pub reasoning_only_strikes: usize,
32    /// Number of consecutive LLM turns that produced a completely empty response
33    /// (no text, no reasoning, no tool call). Reset to 0 at the start of each
34    /// turn. Used by the react loop to retry a bounded number of times, then fail
35    /// instead of looping forever.
36    pub empty_response_strikes: usize,
37}
38
39impl AgentSession {
40    pub fn new(id: SessionId) -> Self {
41        Self {
42            id: Some(id),
43            chat_messages: Vec::new(),
44            always_allowed_actions: HashSet::new(),
45            total_tool_calls: 0,
46            nudge_count: 0,
47            turn_tool_calls: 0,
48            reasoning_only_strikes: 0,
49            empty_response_strikes: 0,
50        }
51    }
52
53    pub fn id(&self) -> Option<SessionId> {
54        self.id.clone()
55    }
56
57    /// Derive a simplified `Vec<Message>` view from the canonical `chat_messages`.
58    /// Assistant messages that contain only tool_calls (no text content) are
59    /// skipped, since they have no corresponding simplified representation.
60    pub fn simple_messages(&self) -> Vec<Message> {
61        self.chat_messages
62            .iter()
63            .filter_map(|cm| match cm {
64                ChatMessage::Assistant { content: None, .. } => None,
65                ChatMessage::Assistant {
66                    content: Some(c),
67                    tool_calls: Some(tc),
68                    ..
69                } if c.is_empty() && !tc.is_empty() => None,
70                _ => Some(Message::from(cm)),
71            })
72            .collect()
73    }
74
75    pub fn chat_messages(&self) -> &[ChatMessage] {
76        &self.chat_messages
77    }
78
79    /// 可变引用,仅用于需要直接操作消息的高级场景。
80    pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
81        &mut self.chat_messages
82    }
83
84    pub fn is_action_allowed(&self, action_key: &str) -> bool {
85        self.always_allowed_actions.contains(action_key)
86    }
87
88    pub fn allow_action(&mut self, action_key: impl Into<String>) {
89        self.always_allowed_actions.insert(action_key.into());
90    }
91
92    pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
93        let content = content.into();
94        let chat_msg = match role {
95            MessageRole::System => ChatMessage::system(content),
96            MessageRole::User => ChatMessage::user(content),
97            MessageRole::Assistant => ChatMessage::assistant(content),
98            MessageRole::Tool => ChatMessage::tool(String::new(), content),
99        };
100        self.chat_messages.push(chat_msg);
101    }
102
103    /// Push an assistant message with reasoning/thinking content preserved.
104    /// This allows the LLM to see its own prior reasoning in subsequent turns,
105    /// preventing it from re-deriving the same conclusions every turn.
106    pub fn push_assistant_with_reasoning(
107        &mut self,
108        content: impl Into<String>,
109        reasoning: impl Into<String>,
110    ) {
111        self.chat_messages
112            .push(ChatMessage::assistant_with_reasoning(content, reasoning));
113    }
114
115    pub fn push_user_message_with_images(
116        &mut self,
117        content: impl Into<String>,
118        images: Vec<ImageAttachment>,
119    ) {
120        self.chat_messages
121            .push(ChatMessage::user_with_images(content, images));
122    }
123
124    pub fn push_assistant_tool_call(
125        &mut self,
126        tool_call_id: &str,
127        tool_name: &str,
128        arguments_json: &str,
129    ) {
130        self.chat_messages.push(ChatMessage::assistant_tool_call(
131            tool_call_id,
132            tool_name,
133            arguments_json,
134        ));
135    }
136
137    pub fn push_assistant_tool_calls(
138        &mut self,
139        tool_calls: &[(String, String, String)],
140        reasoning: Option<String>,
141    ) {
142        let calls: Vec<ToolCallMessage> = tool_calls
143            .iter()
144            .map(|(id, name, args)| ToolCallMessage {
145                id: id.clone(),
146                name: name.clone(),
147                arguments: args.clone(),
148            })
149            .collect();
150        self.chat_messages.push(ChatMessage::Assistant {
151            content: None,
152            reasoning_content: reasoning,
153            tool_calls: Some(calls),
154        });
155    }
156
157    pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
158        self.chat_messages
159            .push(ChatMessage::tool(tool_call_id, content));
160    }
161
162    /// 移除所有临时消息(ephemeral=true)。
163    ///
164    /// 在 turn 结束时调用,确保注入的临时内容不残留到下一轮。
165    pub fn remove_ephemeral_messages(&mut self) {
166        let before = self.chat_messages.len();
167        self.chat_messages.retain(|m| !m.is_ephemeral());
168        let removed = before - self.chat_messages.len();
169        if removed > 0 {
170            tracing::debug!(
171                removed,
172                remaining = self.chat_messages.len(),
173                "ephemeral messages cleaned up"
174            );
175        }
176    }
177
178    /// Count the number of conversation turns.
179    /// A turn starts with a User message and includes subsequent Assistant/Tool messages.
180    pub fn turn_count(&self) -> usize {
181        self.chat_messages
182            .iter()
183            .filter(|m| matches!(m, ChatMessage::User { .. }))
184            .count()
185    }
186
187    /// Remove the oldest turns from the front until turn count ≤ max_turns.
188    /// Preserves the System message at index 0 if present.
189    pub fn trim_oldest_turns(&mut self, max_turns: usize) {
190        let current_turns = self.turn_count();
191        if current_turns <= max_turns {
192            return;
193        }
194        let turns_to_remove = current_turns - max_turns;
195
196        // Find User message positions (turn boundaries) in chat_messages
197        let user_positions: Vec<usize> = self
198            .chat_messages
199            .iter()
200            .enumerate()
201            .filter_map(|(i, m)| {
202                if matches!(m, ChatMessage::User { .. }) {
203                    Some(i)
204                } else {
205                    None
206                }
207            })
208            .collect();
209
210        if user_positions.len() <= turns_to_remove {
211            return;
212        }
213
214        // Preserve system prefix: count leading System messages
215        let system_prefix = self
216            .chat_messages
217            .iter()
218            .take_while(|m| matches!(m, ChatMessage::System { .. }))
219            .count();
220
221        // Drain from system_prefix up to the start of the (turns_to_remove + 1)-th turn
222        let drain_end = user_positions[turns_to_remove];
223        if system_prefix >= drain_end {
224            return; // nothing to drain after system messages
225        }
226
227        self.chat_messages.drain(system_prefix..drain_end);
228    }
229
230    /// Remove the last message from `chat_messages`.
231    /// Used by the max_message_tokens safety valve to discard oversized messages.
232    pub fn pop_last_message(&mut self) {
233        self.chat_messages.pop();
234    }
235
236    pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
237        let assistant_idx = self.chat_messages.iter().rposition(
238            |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
239        );
240
241        let Some(assistant_idx) = assistant_idx else {
242            return;
243        };
244
245        let ChatMessage::Assistant {
246            tool_calls: Some(tc),
247            ..
248        } = &self.chat_messages[assistant_idx]
249        else {
250            return;
251        };
252
253        let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
254
255        let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
256            .iter()
257            .filter_map(|m| match m {
258                ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
259                _ => None,
260            })
261            .collect();
262
263        for id in &all_ids {
264            if !answered_ids.iter().any(|a| a == id) {
265                self.push_tool_result(id, error_summary);
266            }
267        }
268    }
269
270    /// Replace chat messages — only for persistence restore.
271    /// Validates message sequence before replacing.
272    ///
273    /// 仅供持久化恢复使用。调用方必须保证 messages 序列合法。
274    pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
275        validate_message_sequence(&messages)?;
276        // Recalculate total_tool_calls from the incoming messages so middleware
277        // decisions (e.g. first_turn_only enforcement) see the correct count.
278        self.total_tool_calls = messages
279            .iter()
280            .filter_map(|m| match m {
281                ChatMessage::Assistant {
282                    tool_calls: Some(tc),
283                    ..
284                } => Some(tc.len()),
285                _ => None,
286            })
287            .sum();
288        self.chat_messages = messages;
289        Ok(())
290    }
291}
292
293/// Validate that a chat message sequence is well-formed for LLM API consumption.
294///
295/// Checks:
296/// - No Tool message without a preceding Assistant with matching tool_call
297/// - No duplicate Tool messages for the same tool_call_id
298/// - All tool_calls in an Assistant batch must be answered before the next Assistant batch
299/// - No unanswered tool calls at the end of the sequence
300pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
301    let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
302
303    for (i, msg) in messages.iter().enumerate() {
304        match msg {
305            ChatMessage::Tool { tool_call_id, .. } => {
306                if pending_tool_call_ids.is_empty() {
307                    return Err(format!(
308                        "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
309                        i, tool_call_id
310                    ));
311                }
312                // Remove the ID on match — also detects duplicates (second remove returns false)
313                if !pending_tool_call_ids.remove(tool_call_id) {
314                    return Err(format!(
315                        "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
316                        i, tool_call_id
317                    ));
318                }
319            }
320            ChatMessage::Assistant {
321                tool_calls: Some(tc),
322                ..
323            } => {
324                // Previous batch must be fully answered before a new batch starts
325                if !pending_tool_call_ids.is_empty() {
326                    return Err(format!(
327                        "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
328                        i, pending_tool_call_ids
329                    ));
330                }
331                pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
332            }
333            _ => {}
334        }
335    }
336
337    // All tool calls must be answered by the end of the sequence
338    if !pending_tool_call_ids.is_empty() {
339        return Err(format!(
340            "message sequence ends with unanswered tool calls: {:?}",
341            pending_tool_call_ids
342        ));
343    }
344
345    Ok(())
346}
347
348#[cfg(test)]
349fn make_session() -> AgentSession {
350    AgentSession::new(SessionId::new(1))
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_turn_count_empty() {
359        let s = make_session();
360        assert_eq!(s.turn_count(), 0);
361    }
362
363    #[test]
364    fn test_turn_count_with_system_and_user() {
365        let mut s = make_session();
366        s.push_message(MessageRole::System, "system");
367        assert_eq!(s.turn_count(), 0);
368        s.push_message(MessageRole::User, "hello");
369        assert_eq!(s.turn_count(), 1);
370        s.push_message(MessageRole::Assistant, "hi");
371        assert_eq!(s.turn_count(), 1);
372        s.push_message(MessageRole::User, "bye");
373        assert_eq!(s.turn_count(), 2);
374    }
375
376    #[test]
377    fn test_turn_count_with_tool_calls() {
378        let mut s = make_session();
379        s.push_message(MessageRole::User, "do something");
380        s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None);
381        s.push_tool_result("id1", "result");
382        s.push_message(MessageRole::Assistant, "done");
383        // One user turn: User -> Assistant(tool_calls) -> Tool -> Assistant(text)
384        assert_eq!(s.turn_count(), 1);
385    }
386
387    #[test]
388    fn test_trim_oldest_turns_noop() {
389        let mut s = make_session();
390        s.push_message(MessageRole::User, "hello");
391        s.push_message(MessageRole::Assistant, "hi");
392        s.trim_oldest_turns(5);
393        assert_eq!(s.turn_count(), 1);
394        assert_eq!(s.chat_messages().len(), 2);
395    }
396
397    #[test]
398    fn test_trim_oldest_turns_removes_old() {
399        let mut s = make_session();
400        s.push_message(MessageRole::System, "sys");
401        // Turn 1
402        s.push_message(MessageRole::User, "u1");
403        s.push_message(MessageRole::Assistant, "a1");
404        // Turn 2
405        s.push_message(MessageRole::User, "u2");
406        s.push_message(MessageRole::Assistant, "a2");
407        // Turn 3
408        s.push_message(MessageRole::User, "u3");
409        s.push_message(MessageRole::Assistant, "a3");
410
411        s.trim_oldest_turns(2);
412        assert_eq!(s.turn_count(), 2);
413        // System message preserved
414        assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
415        // Oldest user message is u2
416        assert!(
417            matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
418        );
419    }
420
421    #[test]
422    fn test_trim_oldest_turns_with_tool_calls() {
423        let mut s = make_session();
424        // Turn 1 with tool call
425        s.push_message(MessageRole::User, "u1");
426        s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
427        s.push_tool_result("id1", "r1");
428        s.push_message(MessageRole::Assistant, "a1");
429        // Turn 2
430        s.push_message(MessageRole::User, "u2");
431        s.push_message(MessageRole::Assistant, "a2");
432
433        let msg_before = s.simple_messages().len();
434        let chat_before = s.chat_messages().len();
435        s.trim_oldest_turns(1);
436        assert_eq!(s.turn_count(), 1);
437        // chat_messages should have lost 4 entries (User, Assistant(tool), Tool, Assistant(text))
438        assert_eq!(s.chat_messages().len(), chat_before - 4);
439        // simple_messages (derived from chat_messages, tool_calls-only filtered) loses 3 entries
440        assert_eq!(s.simple_messages().len(), msg_before - 3);
441    }
442
443    #[test]
444    fn test_pop_last_message_text() {
445        let mut s = make_session();
446        s.push_message(MessageRole::User, "hello");
447        s.push_message(MessageRole::Assistant, "hi");
448        assert_eq!(s.chat_messages().len(), 2);
449        s.pop_last_message();
450        assert_eq!(s.chat_messages().len(), 1);
451        assert_eq!(s.simple_messages().len(), 1);
452    }
453
454    #[test]
455    fn test_pop_last_message_tool_calls_only() {
456        let mut s = make_session();
457        s.push_message(MessageRole::User, "do it");
458        s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
459        assert_eq!(s.chat_messages().len(), 2);
460        assert_eq!(s.simple_messages().len(), 1); // only User in simple_messages (tool_calls-only filtered)
461        s.pop_last_message();
462        assert_eq!(s.chat_messages().len(), 1);
463        assert_eq!(s.simple_messages().len(), 1); // simple_messages unchanged (still just User)
464    }
465
466    #[test]
467    fn test_pop_last_message_empty_session() {
468        let mut s = make_session();
469        s.pop_last_message(); // should not panic
470        assert_eq!(s.chat_messages().len(), 0);
471    }
472
473    // ── B5: remaining session lifecycle paths ──────────────────────────────
474
475    #[test]
476    fn test_id_and_action_allowlist() {
477        let mut s = make_session();
478        assert_eq!(s.id(), Some(SessionId::new(1)));
479        assert!(!s.is_action_allowed("approve:rm"));
480        s.allow_action("approve:rm");
481        assert!(s.is_action_allowed("approve:rm"));
482        assert!(!s.is_action_allowed("approve:shell"));
483    }
484
485    #[test]
486    fn test_chat_messages_mut() {
487        let mut s = make_session();
488        s.chat_messages_mut().push(ChatMessage::user("direct"));
489        assert_eq!(s.chat_messages().len(), 1);
490    }
491
492    #[test]
493    fn test_push_message_tool_role() {
494        let mut s = make_session();
495        s.push_message(MessageRole::Tool, "result");
496        assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
497    }
498
499    #[test]
500    fn test_push_assistant_with_reasoning() {
501        let mut s = make_session();
502        s.push_assistant_with_reasoning("answer", "thinking");
503        match &s.chat_messages()[0] {
504            ChatMessage::Assistant {
505                content,
506                reasoning_content,
507                ..
508            } => {
509                assert_eq!(content.as_deref(), Some("answer"));
510                assert_eq!(reasoning_content.as_deref(), Some("thinking"));
511            }
512            other => panic!("unexpected message: {other:?}"),
513        }
514    }
515
516    #[test]
517    fn test_push_user_message_with_images() {
518        let mut s = make_session();
519        s.push_user_message_with_images(
520            "look",
521            vec![ImageAttachment::Url {
522                url: "http://x".into(),
523                detail: None,
524            }],
525        );
526        match &s.chat_messages()[0] {
527            ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
528            other => panic!("unexpected message: {other:?}"),
529        }
530    }
531
532    #[test]
533    fn test_push_assistant_tool_call_singular() {
534        let mut s = make_session();
535        s.push_assistant_tool_call("call_1", "bash", "{}");
536        match &s.chat_messages()[0] {
537            ChatMessage::Assistant {
538                tool_calls: Some(tc),
539                ..
540            } => {
541                assert_eq!(tc.len(), 1);
542                assert_eq!(tc[0].id, "call_1");
543                assert_eq!(tc[0].name, "bash");
544            }
545            other => panic!("unexpected message: {other:?}"),
546        }
547    }
548
549    #[test]
550    fn test_simple_messages_filters_empty_content_tool_calls() {
551        let mut s = make_session();
552        s.chat_messages_mut().push(ChatMessage::Assistant {
553            content: Some(String::new()),
554            reasoning_content: None,
555            tool_calls: Some(vec![ToolCallMessage {
556                id: "c".into(),
557                name: "t".into(),
558                arguments: "{}".into(),
559            }]),
560        });
561        assert!(s.simple_messages().is_empty());
562    }
563
564    #[test]
565    fn test_remove_ephemeral_messages() {
566        let mut s = make_session();
567        s.push_message(MessageRole::System, "keep");
568        s.chat_messages_mut()
569            .push(ChatMessage::user_ephemeral("temp"));
570        s.chat_messages_mut()
571            .push(ChatMessage::system_ephemeral("temp2"));
572        s.push_message(MessageRole::User, "keep2");
573        assert_eq!(s.chat_messages().len(), 4);
574        s.remove_ephemeral_messages();
575        assert_eq!(s.chat_messages().len(), 2);
576        assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
577    }
578
579    #[test]
580    fn test_close_dangling_tool_calls_noop_without_tool_call() {
581        let mut s = make_session();
582        s.push_message(MessageRole::User, "hi");
583        s.push_message(MessageRole::Assistant, "hi");
584        s.close_dangling_tool_calls("failed");
585        assert_eq!(s.chat_messages().len(), 2);
586    }
587
588    #[test]
589    fn test_close_dangling_tool_calls_adds_missing_results() {
590        let mut s = make_session();
591        s.push_message(MessageRole::User, "do");
592        s.push_assistant_tool_calls(
593            &[
594                ("c1".into(), "t".into(), "{}".into()),
595                ("c2".into(), "t".into(), "{}".into()),
596            ],
597            None,
598        );
599        s.push_tool_result("c1", "ok"); // only c1 answered
600        s.close_dangling_tool_calls("failed");
601
602        let tool_results: Vec<(String, String)> = s
603            .chat_messages()
604            .iter()
605            .filter_map(|m| match m {
606                ChatMessage::Tool {
607                    tool_call_id,
608                    content,
609                } => Some((tool_call_id.clone(), content.clone())),
610                _ => None,
611            })
612            .collect();
613        assert_eq!(tool_results.len(), 2);
614        assert!(
615            tool_results
616                .iter()
617                .any(|(id, c)| id == "c2" && c == "failed")
618        );
619    }
620
621    #[test]
622    fn test_set_chat_messages_recalculates_total_tool_calls() {
623        let mut s = make_session();
624        let msgs = vec![
625            ChatMessage::user("do"),
626            ChatMessage::assistant_tool_call("c1", "t", "{}"),
627            ChatMessage::tool("c1", "result"),
628        ];
629        s.set_chat_messages(msgs).unwrap();
630        assert_eq!(s.total_tool_calls, 1);
631    }
632}
633
634#[cfg(test)]
635mod validate_tests {
636    use super::*;
637
638    #[test]
639    fn test_valid_simple_sequence() {
640        let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
641        assert!(validate_message_sequence(&msgs).is_ok());
642    }
643
644    #[test]
645    fn test_valid_tool_call_sequence() {
646        let msgs = vec![
647            ChatMessage::user("run command"),
648            ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
649            ChatMessage::tool("call_1", "file1 file2"),
650            ChatMessage::assistant("done"),
651        ];
652        assert!(validate_message_sequence(&msgs).is_ok());
653    }
654
655    #[test]
656    fn test_valid_multi_tool_call_sequence() {
657        let msgs = vec![
658            ChatMessage::user("run commands"),
659            ChatMessage::Assistant {
660                content: None,
661                reasoning_content: None,
662                tool_calls: Some(vec![
663                    crate::types::ToolCallMessage {
664                        id: "call_1".into(),
665                        name: "bash".into(),
666                        arguments: "{}".into(),
667                    },
668                    crate::types::ToolCallMessage {
669                        id: "call_2".into(),
670                        name: "read".into(),
671                        arguments: "{}".into(),
672                    },
673                ]),
674            },
675            ChatMessage::tool("call_1", "result1"),
676            ChatMessage::tool("call_2", "result2"),
677            ChatMessage::assistant("done"),
678        ];
679        assert!(validate_message_sequence(&msgs).is_ok());
680    }
681
682    #[test]
683    fn test_orphaned_tool_result() {
684        let msgs = vec![
685            ChatMessage::user("hello"),
686            ChatMessage::tool("call_1", "orphaned result"),
687        ];
688        let err = validate_message_sequence(&msgs).unwrap_err();
689        assert!(err.contains("no preceding tool_call"));
690    }
691
692    #[test]
693    fn test_mismatched_tool_call_id() {
694        let msgs = vec![
695            ChatMessage::user("run"),
696            ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
697            ChatMessage::tool("call_2", "wrong id"),
698        ];
699        let err = validate_message_sequence(&msgs).unwrap_err();
700        assert!(err.contains("does not match"));
701    }
702
703    #[test]
704    fn test_set_chat_messages_valid() {
705        let mut s = make_session();
706        let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
707        assert!(s.set_chat_messages(msgs.clone()).is_ok());
708        assert_eq!(s.chat_messages().len(), 2);
709    }
710
711    #[test]
712    fn test_set_chat_messages_invalid() {
713        let mut s = make_session();
714        let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
715        assert!(s.set_chat_messages(msgs).is_err());
716    }
717}