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/// Run-level state tracking for the react loop.
10///
11/// Manages counters and flags that track the current run's progress.
12/// All fields are reset at the start of each run (when a new user message arrives).
13///
14/// # Backward compatibility
15///
16/// Before this struct existed, `nudge_count` and `turn_tool_calls` were flat
17/// fields on `AgentSession`.  The [`RawAgentSession`] deserialization shim
18/// migrates them automatically.
19#[derive(Clone, Debug, Default, Serialize, Deserialize)]
20pub struct RunState {
21    /// Number of tool calls already executed in the current turn.
22    /// Reset to 0 at the start of each turn (when a new user message arrives).
23    /// Used by `TurnToolLimitMiddleware` to enforce per-turn tool call limits.
24    pub turn_tool_calls: usize,
25    /// Whether any tools were called in the current run.
26    /// Reset to false at the start of each run. Used by the completion judge
27    /// to determine if tools were used before a text-only response.
28    pub run_has_tool_calls: bool,
29    /// Number of consecutive LLM turns that produced only reasoning_content
30    /// (no text, no tool call). Reset to 0 when a normal response or tool call
31    /// is produced. Used by the react loop to fail instead of looping forever
32    /// on a reasoning-model runaway.
33    pub reasoning_only_strikes: usize,
34    /// Number of consecutive LLM turns that produced a completely empty response
35    /// (no text, no reasoning, no tool call). Reset to 0 when a normal response
36    /// or tool call is produced. Used by the react loop to retry a bounded
37    /// number of times, then fail instead of looping forever.
38    pub empty_response_strikes: usize,
39    /// Number of tool-enforcement nudges issued in the current turn.
40    /// Reset to 0 at the start of each turn (when a new user message arrives).
41    /// Used by `ToolEnforcementMiddleware` to cap nudge attempts per turn.
42    pub nudge_count: usize,
43}
44
45impl RunState {
46    /// Reset all run-level state for a new run (when a new user message arrives).
47    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    /// Record tool calls (branch 3: tool calls).
56    /// Resets reasoning_only_strikes and empty_response_strikes.
57    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    /// Record reasoning-only response (branch 1).
65    /// Resets empty_response_strikes.
66    /// Returns the new strike count.
67    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    /// Record empty response (branch 2).
74    /// Resets reasoning_only_strikes.
75    /// Returns the new strike count.
76    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/// Deserialization shim for backward compatibility.
84///
85/// Before `RunState` was introduced, `nudge_count`, `turn_tool_calls`,
86/// `reasoning_only_strikes`, and `empty_response_strikes` were flat fields
87/// on `AgentSession`.  This struct accepts **both** the old flat format and
88/// the new nested `run_state` format, migrating legacy data on the fly.
89#[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    // ── new format (preferred) ──
97    run_state: Option<RunState>,
98
99    // ── legacy flat fields (fallback) ──
100    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/// Stable identity + rolling state of a single chat thread.
126///
127/// `Default` returns a fresh session; load persisted state via serde.
128/// Backward-compatible with the old flat-field format (pre-`RunState` migration).
129#[derive(Clone, Debug, Default, Serialize, Deserialize)]
130#[serde(from = "RawAgentSession")]
131pub struct AgentSession {
132    id: Option<SessionId>,
133    /// LLM API format messages, sent directly to the provider.
134    /// This is the single source of truth for the conversation state.
135    chat_messages: Vec<ChatMessage>,
136    always_allowed_actions: HashSet<String>,
137    /// Total number of tool calls made in this session (across all turns).
138    /// Used by middleware for decisions like "first_turn_only" enforcement.
139    pub total_tool_calls: usize,
140    /// Run-level state tracking for the react loop.
141    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    /// Derive a simplified `Vec<Message>` view from the canonical `chat_messages`.
160    /// Assistant messages that contain only tool_calls (no text content) are
161    /// skipped, since they have no corresponding simplified representation.
162    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    /// 可变引用,仅用于需要直接操作消息的高级场景。
182    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    /// Push an assistant message with reasoning/thinking content preserved.
206    /// This allows the LLM to see its own prior reasoning in subsequent turns,
207    /// preventing it from re-deriving the same conclusions every turn.
208    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    /// 移除所有临时消息(ephemeral=true)。
265    ///
266    /// 在 turn 结束时调用,确保注入的临时内容不残留到下一轮。
267    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    /// Count the number of conversation turns.
281    /// A turn starts with a User message and includes subsequent Assistant/Tool messages.
282    pub fn turn_count(&self) -> usize {
283        self.chat_messages
284            .iter()
285            .filter(|m| matches!(m, ChatMessage::User { .. }))
286            .count()
287    }
288
289    /// Remove the oldest turns from the front until turn count ≤ max_turns.
290    /// Preserves the System message at index 0 if present.
291    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        // Find User message positions (turn boundaries) in chat_messages
299        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        // Preserve system prefix: count leading System messages
317        let system_prefix = self
318            .chat_messages
319            .iter()
320            .take_while(|m| matches!(m, ChatMessage::System { .. }))
321            .count();
322
323        // Drain from system_prefix up to the start of the (turns_to_remove + 1)-th turn
324        let drain_end = user_positions[turns_to_remove];
325        if system_prefix >= drain_end {
326            return; // nothing to drain after system messages
327        }
328
329        self.chat_messages.drain(system_prefix..drain_end);
330    }
331
332    /// Remove the last message from `chat_messages`.
333    /// Used by the max_message_tokens safety valve to discard oversized messages.
334    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    /// Replace chat messages — only for persistence restore.
373    /// Validates message sequence before replacing.
374    ///
375    /// 仅供持久化恢复使用。调用方必须保证 messages 序列合法。
376    pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
377        validate_message_sequence(&messages)?;
378        // Recalculate total_tool_calls from the incoming messages so middleware
379        // decisions (e.g. first_turn_only enforcement) see the correct count.
380        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
395/// Validate that a chat message sequence is well-formed for LLM API consumption.
396///
397/// Checks:
398/// - No Tool message without a preceding Assistant with matching tool_call
399/// - No duplicate Tool messages for the same tool_call_id
400/// - All tool_calls in an Assistant batch must be answered before the next Assistant batch
401/// - No unanswered tool calls at the end of the sequence
402pub 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                // Remove the ID on match — also detects duplicates (second remove returns false)
415                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                // Previous batch must be fully answered before a new batch starts
427                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    // All tool calls must be answered by the end of the sequence
440    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        // One user turn: User -> Assistant(tool_calls) -> Tool -> Assistant(text)
486        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        // Turn 1
504        s.push_message(MessageRole::User, "u1");
505        s.push_message(MessageRole::Assistant, "a1");
506        // Turn 2
507        s.push_message(MessageRole::User, "u2");
508        s.push_message(MessageRole::Assistant, "a2");
509        // Turn 3
510        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        // System message preserved
516        assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
517        // Oldest user message is u2
518        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        // Turn 1 with tool call
527        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        // Turn 2
532        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        // chat_messages should have lost 4 entries (User, Assistant(tool), Tool, Assistant(text))
540        assert_eq!(s.chat_messages().len(), chat_before - 4);
541        // simple_messages (derived from chat_messages, tool_calls-only filtered) loses 3 entries
542        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); // only User in simple_messages (tool_calls-only filtered)
563        s.pop_last_message();
564        assert_eq!(s.chat_messages().len(), 1);
565        assert_eq!(s.simple_messages().len(), 1); // simple_messages unchanged (still just User)
566    }
567
568    #[test]
569    fn test_pop_last_message_empty_session() {
570        let mut s = make_session();
571        s.pop_last_message(); // should not panic
572        assert_eq!(s.chat_messages().len(), 0);
573    }
574
575    // ── B5: remaining session lifecycle paths ──────────────────────────────
576
577    #[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"); // only c1 answered
702        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    // ── RunState tests ────────────────────────────────────────────────────
821
822    #[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); // reset
864        assert_eq!(rs.empty_response_strikes, 0); // reset
865    }
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); // reset
889    }
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); // reset
912    }
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        // Simulate: reasoning only → tool calls → reasoning only
926        let mut rs = RunState::default();
927
928        // Branch 1: reasoning only
929        rs.record_reasoning_only();
930        assert_eq!(rs.reasoning_only_strikes, 1);
931
932        // Branch 3: tool calls (should reset reasoning_only_strikes)
933        rs.record_tool_calls(2);
934        assert_eq!(rs.reasoning_only_strikes, 0);
935        assert_eq!(rs.turn_tool_calls, 2);
936
937        // Branch 1 again: reasoning only (should start from 1, not 2)
938        let strikes = rs.record_reasoning_only();
939        assert_eq!(strikes, 1);
940    }
941
942    #[test]
943    fn run_state_empty_to_reasoning_reset() {
944        // Simulate: empty → empty → reasoning only (should reset empty strikes)
945        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        // Branch 1: reasoning only (should reset empty_response_strikes)
952        rs.record_reasoning_only();
953        assert_eq!(rs.empty_response_strikes, 0);
954        assert_eq!(rs.reasoning_only_strikes, 1);
955    }
956
957    // ── Backward-compatible deserialization ────────────────────────────────
958
959    #[test]
960    fn deserialize_legacy_flat_fields() {
961        // Old format: nudge_count, turn_tool_calls, etc. as flat fields
962        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); // default
978    }
979
980    #[test]
981    fn deserialize_new_run_state_format() {
982        // New format: nested run_state
983        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        // When both are present, run_state wins
1006        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); // run_state wins
1023        assert_eq!(session.run_state.nudge_count, 0); // run_state wins
1024    }
1025
1026    #[test]
1027    fn deserialize_legacy_missing_optional_fields() {
1028        // Old format with some fields missing (defaults to 0)
1029        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); // missing → 0
1039        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}