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    /// Thinking is disabled for the rest of the current run.
44    /// Set to true when reasoning_only_strikes reaches the maximum (3).
45    /// Reset to false at the start of each new run (when a new user message arrives).
46    /// This allows the model to continue without thinking after too many
47    /// reasoning-only responses.
48    #[serde(default)]
49    pub thinking_disabled_for_rest_of_run: bool,
50
51    // ─── New fields for thinking guard ─────────────────────────────
52    /// Original thinking configuration (for restoration)
53    ///
54    /// Records whether thinking was originally enabled when the session started.
55    /// Used to restore thinking to its original state after DisableThinking → RestoreThinking cycle.
56    #[serde(default)]
57    pub original_thinking_enabled: bool,
58
59    /// Number of consecutive LLM turns where the truncation guard fired
60    /// (all tool_calls had invalid/truncated arguments). Reset to 0 when
61    /// at least one valid tool call executes successfully. Used to break
62    /// the re-issue death spiral: after N consecutive truncations, stop
63    /// retrying and fail with an actionable message.
64    #[serde(default)]
65    pub truncation_strikes: usize,
66}
67
68impl RunState {
69    /// Reset all run-level state for a new run (when a new user message arrives).
70    pub fn reset_for_new_run(&mut self) {
71        self.turn_tool_calls = 0;
72        self.run_has_tool_calls = false;
73        self.reasoning_only_strikes = 0;
74        self.empty_response_strikes = 0;
75        self.nudge_count = 0;
76        self.thinking_disabled_for_rest_of_run = false;
77        self.truncation_strikes = 0;
78        // Note: original_thinking_enabled is NOT reset here
79        // It should be set once when the session starts
80    }
81
82    /// Record tool calls (branch 3: tool calls).
83    /// Resets reasoning_only_strikes, empty_response_strikes, and truncation_strikes.
84    pub fn record_tool_calls(&mut self, n: usize) {
85        self.turn_tool_calls += n;
86        self.run_has_tool_calls = true;
87        self.reasoning_only_strikes = 0;
88        self.empty_response_strikes = 0;
89        self.truncation_strikes = 0;
90    }
91
92    /// Record reasoning-only response (branch 1).
93    /// Resets empty_response_strikes.
94    /// Returns the new strike count.
95    /// If strikes reach 3, disables thinking for the rest of the run.
96    pub fn record_reasoning_only(&mut self) -> usize {
97        self.empty_response_strikes = 0;
98        self.reasoning_only_strikes += 1;
99        if self.reasoning_only_strikes >= 3 {
100            tracing::info!(
101                strikes = self.reasoning_only_strikes,
102                "reasoning_only_strikes reached 3, disabling thinking for rest of run"
103            );
104            self.thinking_disabled_for_rest_of_run = true;
105        }
106        self.reasoning_only_strikes
107    }
108
109    /// Record empty response (branch 2).
110    /// Resets reasoning_only_strikes.
111    /// Returns the new strike count.
112    pub fn record_empty_response(&mut self) -> usize {
113        self.reasoning_only_strikes = 0;
114        self.empty_response_strikes += 1;
115        self.empty_response_strikes
116    }
117
118    /// Record a truncation guard firing (all tool_calls had invalid arguments).
119    /// Returns the new strike count. Caller should check against threshold.
120    pub fn record_truncation(&mut self) -> usize {
121        self.truncation_strikes += 1;
122        self.truncation_strikes
123    }
124}
125
126/// Deserialization shim for backward compatibility.
127///
128/// Before `RunState` was introduced, `nudge_count`, `turn_tool_calls`,
129/// `reasoning_only_strikes`, and `empty_response_strikes` were flat fields
130/// on `AgentSession`.  This struct accepts **both** the old flat format and
131/// the new nested `run_state` format, migrating legacy data on the fly.
132#[derive(Deserialize)]
133struct RawAgentSession {
134    id: Option<SessionId>,
135    chat_messages: Vec<ChatMessage>,
136    always_allowed_actions: HashSet<String>,
137    total_tool_calls: usize,
138
139    // ── new format (preferred) ──
140    run_state: Option<RunState>,
141
142    // ── legacy flat fields (fallback) ──
143    nudge_count: Option<usize>,
144    turn_tool_calls: Option<usize>,
145    reasoning_only_strikes: Option<usize>,
146    empty_response_strikes: Option<usize>,
147}
148
149impl From<RawAgentSession> for AgentSession {
150    fn from(raw: RawAgentSession) -> Self {
151        let run_state = raw.run_state.unwrap_or_else(|| RunState {
152            nudge_count: raw.nudge_count.unwrap_or(0),
153            turn_tool_calls: raw.turn_tool_calls.unwrap_or(0),
154            reasoning_only_strikes: raw.reasoning_only_strikes.unwrap_or(0),
155            empty_response_strikes: raw.empty_response_strikes.unwrap_or(0),
156            ..RunState::default()
157        });
158        Self {
159            id: raw.id,
160            chat_messages: raw.chat_messages,
161            always_allowed_actions: raw.always_allowed_actions,
162            total_tool_calls: raw.total_tool_calls,
163            run_state,
164        }
165    }
166}
167
168/// Stable identity + rolling state of a single chat thread.
169///
170/// `Default` returns a fresh session; load persisted state via serde.
171/// Backward-compatible with the old flat-field format (pre-`RunState` migration).
172#[derive(Clone, Debug, Default, Serialize, Deserialize)]
173#[serde(from = "RawAgentSession")]
174pub struct AgentSession {
175    id: Option<SessionId>,
176    /// LLM API format messages, sent directly to the provider.
177    /// This is the single source of truth for the conversation state.
178    chat_messages: Vec<ChatMessage>,
179    always_allowed_actions: HashSet<String>,
180    /// Total number of tool calls made in this session (across all turns).
181    /// Used by middleware for decisions like "first_turn_only" enforcement.
182    pub total_tool_calls: usize,
183    /// Run-level state tracking for the react loop.
184    pub run_state: RunState,
185}
186
187impl AgentSession {
188    pub fn new(id: SessionId) -> Self {
189        Self {
190            id: Some(id),
191            chat_messages: Vec::new(),
192            always_allowed_actions: HashSet::new(),
193            total_tool_calls: 0,
194            run_state: RunState::default(),
195        }
196    }
197
198    pub fn id(&self) -> Option<SessionId> {
199        self.id.clone()
200    }
201
202    /// Derive a simplified `Vec<Message>` view from the canonical `chat_messages`.
203    /// Assistant messages that contain only tool_calls (no text content) are
204    /// skipped, since they have no corresponding simplified representation.
205    pub fn simple_messages(&self) -> Vec<Message> {
206        self.chat_messages
207            .iter()
208            .filter_map(|cm| match cm {
209                ChatMessage::Assistant { content: None, .. } => None,
210                ChatMessage::Assistant {
211                    content: Some(c),
212                    tool_calls: Some(tc),
213                    ..
214                } if c.is_empty() && !tc.is_empty() => None,
215                _ => Some(Message::from(cm)),
216            })
217            .collect()
218    }
219
220    pub fn chat_messages(&self) -> &[ChatMessage] {
221        &self.chat_messages
222    }
223
224    /// 可变引用,仅用于需要直接操作消息的高级场景。
225    pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
226        &mut self.chat_messages
227    }
228
229    pub fn is_action_allowed(&self, action_key: &str) -> bool {
230        self.always_allowed_actions.contains(action_key)
231    }
232
233    pub fn allow_action(&mut self, action_key: impl Into<String>) {
234        self.always_allowed_actions.insert(action_key.into());
235    }
236
237    pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
238        let content = content.into();
239        let chat_msg = match role {
240            MessageRole::System => ChatMessage::system(content),
241            MessageRole::User => ChatMessage::user(content),
242            MessageRole::Assistant => ChatMessage::assistant(content),
243            MessageRole::Tool => ChatMessage::tool(String::new(), content),
244        };
245        self.chat_messages.push(chat_msg);
246    }
247
248    /// Push an assistant message with reasoning/thinking content preserved.
249    /// This allows the LLM to see its own prior reasoning in subsequent turns,
250    /// preventing it from re-deriving the same conclusions every turn.
251    pub fn push_assistant_with_reasoning(
252        &mut self,
253        content: impl Into<String>,
254        reasoning: impl Into<String>,
255    ) {
256        self.chat_messages
257            .push(ChatMessage::assistant_with_reasoning(content, reasoning));
258    }
259
260    pub fn push_user_message_with_images(
261        &mut self,
262        content: impl Into<String>,
263        images: Vec<ImageAttachment>,
264    ) {
265        self.chat_messages
266            .push(ChatMessage::user_with_images(content, images));
267    }
268
269    pub fn push_assistant_tool_call(
270        &mut self,
271        tool_call_id: &str,
272        tool_name: &str,
273        arguments_json: &str,
274    ) {
275        self.chat_messages.push(ChatMessage::assistant_tool_call(
276            tool_call_id,
277            tool_name,
278            arguments_json,
279        ));
280    }
281
282    pub fn push_assistant_tool_calls(
283        &mut self,
284        tool_calls: &[(String, String, String)],
285        reasoning: Option<String>,
286        content: Option<String>,
287    ) {
288        let calls: Vec<ToolCallMessage> = tool_calls
289            .iter()
290            .map(|(id, name, args)| {
291                // Invalid JSON args (provider truncated the stream mid-generation)
292                // are sanitized to an empty object. We deliberately do NOT wrap
293                // them in a descriptive `{error, original_args_preview, message}`
294                // object: "{}" is valid JSON so the next request is never rejected
295                // with 400, and — critically — a rich error object here becomes an
296                // imitation vector: the model replays it verbatim as the next
297                // call's arguments and, because it parses, it slips past the react
298                // truncation guard. The truncation explanation lives in the
299                // paired tool_result, which is where the model reads feedback.
300                let valid_args = if serde_json::from_str::<serde_json::Value>(args).is_ok() {
301                    args.clone()
302                } else {
303                    tracing::warn!(
304                        tool_name = %name,
305                        args_len = args.len(),
306                        "tool call arguments are not valid JSON (provider truncated them), \
307                         sanitizing to empty object; re-issue instruction goes to the tool_result"
308                    );
309                    "{}".to_string()
310                };
311                ToolCallMessage {
312                    id: id.clone(),
313                    name: name.clone(),
314                    arguments: valid_args,
315                }
316            })
317            .collect();
318        self.chat_messages.push(ChatMessage::Assistant {
319            content,
320            reasoning_content: reasoning,
321            tool_calls: Some(calls),
322            thinking_signature: None,
323        });
324    }
325
326    pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
327        self.chat_messages
328            .push(ChatMessage::tool(tool_call_id, content));
329    }
330
331    /// 移除所有临时消息(ephemeral=true)。
332    ///
333    /// 在 turn 结束时调用,确保注入的临时内容不残留到下一轮。
334    pub fn remove_ephemeral_messages(&mut self) {
335        let before = self.chat_messages.len();
336        self.chat_messages.retain(|m| !m.is_ephemeral());
337        let removed = before - self.chat_messages.len();
338        if removed > 0 {
339            tracing::debug!(
340                removed,
341                remaining = self.chat_messages.len(),
342                "ephemeral messages cleaned up"
343            );
344        }
345    }
346
347    /// Count the number of conversation turns.
348    /// A turn starts with a User message and includes subsequent Assistant/Tool messages.
349    pub fn turn_count(&self) -> usize {
350        self.chat_messages
351            .iter()
352            .filter(|m| matches!(m, ChatMessage::User { .. }))
353            .count()
354    }
355
356    /// Remove the oldest turns from the front until turn count ≤ max_turns.
357    /// Preserves the System message at index 0 if present.
358    pub fn trim_oldest_turns(&mut self, max_turns: usize) {
359        let current_turns = self.turn_count();
360        if current_turns <= max_turns {
361            return;
362        }
363        let turns_to_remove = current_turns - max_turns;
364
365        // Find User message positions (turn boundaries) in chat_messages
366        let user_positions: Vec<usize> = self
367            .chat_messages
368            .iter()
369            .enumerate()
370            .filter_map(|(i, m)| {
371                if matches!(m, ChatMessage::User { .. }) {
372                    Some(i)
373                } else {
374                    None
375                }
376            })
377            .collect();
378
379        if user_positions.len() <= turns_to_remove {
380            return;
381        }
382
383        // Preserve system prefix: count leading System messages
384        let system_prefix = self
385            .chat_messages
386            .iter()
387            .take_while(|m| matches!(m, ChatMessage::System { .. }))
388            .count();
389
390        // Drain from system_prefix up to the start of the (turns_to_remove + 1)-th turn
391        let drain_end = user_positions[turns_to_remove];
392        if system_prefix >= drain_end {
393            return; // nothing to drain after system messages
394        }
395
396        self.chat_messages.drain(system_prefix..drain_end);
397    }
398
399    /// Remove the last message from `chat_messages`.
400    /// Used by the max_message_tokens safety valve to discard oversized messages.
401    pub fn pop_last_message(&mut self) {
402        self.chat_messages.pop();
403    }
404
405    pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
406        let assistant_idx = self.chat_messages.iter().rposition(
407            |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
408        );
409
410        let Some(assistant_idx) = assistant_idx else {
411            return;
412        };
413
414        let ChatMessage::Assistant {
415            tool_calls: Some(tc),
416            ..
417        } = &self.chat_messages[assistant_idx]
418        else {
419            return;
420        };
421
422        let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
423
424        let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
425            .iter()
426            .filter_map(|m| match m {
427                ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
428                _ => None,
429            })
430            .collect();
431
432        for id in &all_ids {
433            if !answered_ids.iter().any(|a| a == id) {
434                self.push_tool_result(id, error_summary);
435            }
436        }
437    }
438
439    /// Replace chat messages — only for persistence restore.
440    /// Validates message sequence before replacing.
441    ///
442    /// 仅供持久化恢复使用。调用方必须保证 messages 序列合法。
443    pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
444        validate_message_sequence(&messages)?;
445        // Recalculate total_tool_calls from the incoming messages so middleware
446        // decisions (e.g. first_turn_only enforcement) see the correct count.
447        self.total_tool_calls = messages
448            .iter()
449            .filter_map(|m| match m {
450                ChatMessage::Assistant {
451                    tool_calls: Some(tc),
452                    ..
453                } => Some(tc.len()),
454                _ => None,
455            })
456            .sum();
457        self.chat_messages = messages;
458        Ok(())
459    }
460}
461
462/// Validate that a chat message sequence is well-formed for LLM API consumption.
463///
464/// Checks:
465/// - At least one non-System/non-Custom message (System maps to the
466///   top-level `system` parameter, Custom is stripped — a sequence of only
467///   those leaves an empty `messages` array, which providers reject with
468///   HTTP 400). Guards compaction outputs against producing an unsendable
469///   window.
470/// - No Tool message without a preceding Assistant with matching tool_call
471/// - No duplicate Tool messages for the same tool_call_id
472/// - All tool_calls in an Assistant batch must be answered before the next Assistant batch
473/// - No unanswered tool calls at the end of the sequence
474pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
475    if !messages
476        .iter()
477        .any(|m| !matches!(m, ChatMessage::System { .. } | ChatMessage::Custom { .. }))
478    {
479        return Err(
480            "sequence contains no sendable message: System/Custom alone leave the \
481             provider `messages` array empty"
482                .to_string(),
483        );
484    }
485
486    let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
487
488    for (i, msg) in messages.iter().enumerate() {
489        match msg {
490            ChatMessage::Tool { tool_call_id, .. } => {
491                if pending_tool_call_ids.is_empty() {
492                    return Err(format!(
493                        "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
494                        i, tool_call_id
495                    ));
496                }
497                // Remove the ID on match — also detects duplicates (second remove returns false)
498                if !pending_tool_call_ids.remove(tool_call_id) {
499                    return Err(format!(
500                        "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
501                        i, tool_call_id
502                    ));
503                }
504            }
505            ChatMessage::Assistant {
506                tool_calls: Some(tc),
507                ..
508            } => {
509                // Previous batch must be fully answered before a new batch starts
510                if !pending_tool_call_ids.is_empty() {
511                    return Err(format!(
512                        "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
513                        i, pending_tool_call_ids
514                    ));
515                }
516                pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
517            }
518            _ => {}
519        }
520    }
521
522    // All tool calls must be answered by the end of the sequence
523    if !pending_tool_call_ids.is_empty() {
524        return Err(format!(
525            "message sequence ends with unanswered tool calls: {:?}",
526            pending_tool_call_ids
527        ));
528    }
529
530    Ok(())
531}
532
533#[cfg(test)]
534fn make_session() -> AgentSession {
535    AgentSession::new(SessionId::new(1))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_turn_count_empty() {
544        let s = make_session();
545        assert_eq!(s.turn_count(), 0);
546    }
547
548    #[test]
549    fn test_turn_count_with_system_and_user() {
550        let mut s = make_session();
551        s.push_message(MessageRole::System, "system");
552        assert_eq!(s.turn_count(), 0);
553        s.push_message(MessageRole::User, "hello");
554        assert_eq!(s.turn_count(), 1);
555        s.push_message(MessageRole::Assistant, "hi");
556        assert_eq!(s.turn_count(), 1);
557        s.push_message(MessageRole::User, "bye");
558        assert_eq!(s.turn_count(), 2);
559    }
560
561    #[test]
562    fn test_turn_count_with_tool_calls() {
563        let mut s = make_session();
564        s.push_message(MessageRole::User, "do something");
565        s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None, None);
566        s.push_tool_result("id1", "result");
567        s.push_message(MessageRole::Assistant, "done");
568        // One user turn: User -> Assistant(tool_calls) -> Tool -> Assistant(text)
569        assert_eq!(s.turn_count(), 1);
570    }
571
572    #[test]
573    fn test_trim_oldest_turns_noop() {
574        let mut s = make_session();
575        s.push_message(MessageRole::User, "hello");
576        s.push_message(MessageRole::Assistant, "hi");
577        s.trim_oldest_turns(5);
578        assert_eq!(s.turn_count(), 1);
579        assert_eq!(s.chat_messages().len(), 2);
580    }
581
582    #[test]
583    fn test_trim_oldest_turns_removes_old() {
584        let mut s = make_session();
585        s.push_message(MessageRole::System, "sys");
586        // Turn 1
587        s.push_message(MessageRole::User, "u1");
588        s.push_message(MessageRole::Assistant, "a1");
589        // Turn 2
590        s.push_message(MessageRole::User, "u2");
591        s.push_message(MessageRole::Assistant, "a2");
592        // Turn 3
593        s.push_message(MessageRole::User, "u3");
594        s.push_message(MessageRole::Assistant, "a3");
595
596        s.trim_oldest_turns(2);
597        assert_eq!(s.turn_count(), 2);
598        // System message preserved
599        assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
600        // Oldest user message is u2
601        assert!(
602            matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
603        );
604    }
605
606    #[test]
607    fn test_trim_oldest_turns_with_tool_calls() {
608        let mut s = make_session();
609        // Turn 1 with tool call
610        s.push_message(MessageRole::User, "u1");
611        s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None, None);
612        s.push_tool_result("id1", "r1");
613        s.push_message(MessageRole::Assistant, "a1");
614        // Turn 2
615        s.push_message(MessageRole::User, "u2");
616        s.push_message(MessageRole::Assistant, "a2");
617
618        let msg_before = s.simple_messages().len();
619        let chat_before = s.chat_messages().len();
620        s.trim_oldest_turns(1);
621        assert_eq!(s.turn_count(), 1);
622        // chat_messages should have lost 4 entries (User, Assistant(tool), Tool, Assistant(text))
623        assert_eq!(s.chat_messages().len(), chat_before - 4);
624        // simple_messages (derived from chat_messages, tool_calls-only filtered) loses 3 entries
625        assert_eq!(s.simple_messages().len(), msg_before - 3);
626    }
627
628    #[test]
629    fn test_pop_last_message_text() {
630        let mut s = make_session();
631        s.push_message(MessageRole::User, "hello");
632        s.push_message(MessageRole::Assistant, "hi");
633        assert_eq!(s.chat_messages().len(), 2);
634        s.pop_last_message();
635        assert_eq!(s.chat_messages().len(), 1);
636        assert_eq!(s.simple_messages().len(), 1);
637    }
638
639    #[test]
640    fn test_pop_last_message_tool_calls_only() {
641        let mut s = make_session();
642        s.push_message(MessageRole::User, "do it");
643        s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None, None);
644        assert_eq!(s.chat_messages().len(), 2);
645        assert_eq!(s.simple_messages().len(), 1); // only User in simple_messages (tool_calls-only filtered)
646        s.pop_last_message();
647        assert_eq!(s.chat_messages().len(), 1);
648        assert_eq!(s.simple_messages().len(), 1); // simple_messages unchanged (still just User)
649    }
650
651    #[test]
652    fn test_pop_last_message_empty_session() {
653        let mut s = make_session();
654        s.pop_last_message(); // should not panic
655        assert_eq!(s.chat_messages().len(), 0);
656    }
657
658    // ── B5: remaining session lifecycle paths ──────────────────────────────
659
660    #[test]
661    fn test_id_and_action_allowlist() {
662        let mut s = make_session();
663        assert_eq!(s.id(), Some(SessionId::new(1)));
664        assert!(!s.is_action_allowed("approve:rm"));
665        s.allow_action("approve:rm");
666        assert!(s.is_action_allowed("approve:rm"));
667        assert!(!s.is_action_allowed("approve:shell"));
668    }
669
670    #[test]
671    fn test_chat_messages_mut() {
672        let mut s = make_session();
673        s.chat_messages_mut().push(ChatMessage::user("direct"));
674        assert_eq!(s.chat_messages().len(), 1);
675    }
676
677    #[test]
678    fn test_push_message_tool_role() {
679        let mut s = make_session();
680        s.push_message(MessageRole::Tool, "result");
681        assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
682    }
683
684    #[test]
685    fn test_push_assistant_with_reasoning() {
686        let mut s = make_session();
687        s.push_assistant_with_reasoning("answer", "thinking");
688        match &s.chat_messages()[0] {
689            ChatMessage::Assistant {
690                content,
691                reasoning_content,
692                ..
693            } => {
694                assert_eq!(content.as_deref(), Some("answer"));
695                assert_eq!(reasoning_content.as_deref(), Some("thinking"));
696            }
697            other => panic!("unexpected message: {other:?}"),
698        }
699    }
700
701    #[test]
702    fn test_push_user_message_with_images() {
703        let mut s = make_session();
704        s.push_user_message_with_images(
705            "look",
706            vec![ImageAttachment::Url {
707                url: "http://x".into(),
708                detail: None,
709            }],
710        );
711        match &s.chat_messages()[0] {
712            ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
713            other => panic!("unexpected message: {other:?}"),
714        }
715    }
716
717    #[test]
718    fn test_push_assistant_tool_call_singular() {
719        let mut s = make_session();
720        s.push_assistant_tool_call("call_1", "bash", "{}");
721        match &s.chat_messages()[0] {
722            ChatMessage::Assistant {
723                tool_calls: Some(tc),
724                ..
725            } => {
726                assert_eq!(tc.len(), 1);
727                assert_eq!(tc[0].id, "call_1");
728                assert_eq!(tc[0].name, "bash");
729            }
730            other => panic!("unexpected message: {other:?}"),
731        }
732    }
733
734    #[test]
735    fn test_simple_messages_filters_empty_content_tool_calls() {
736        let mut s = make_session();
737        s.chat_messages_mut().push(ChatMessage::Assistant {
738            content: Some(String::new()),
739            reasoning_content: None,
740            tool_calls: Some(vec![ToolCallMessage {
741                id: "c".into(),
742                name: "t".into(),
743                arguments: "{}".into(),
744            }]),
745            thinking_signature: None,
746        });
747        assert!(s.simple_messages().is_empty());
748    }
749
750    #[test]
751    fn test_remove_ephemeral_messages() {
752        let mut s = make_session();
753        s.push_message(MessageRole::System, "keep");
754        s.chat_messages_mut()
755            .push(ChatMessage::user_ephemeral("temp"));
756        s.chat_messages_mut()
757            .push(ChatMessage::system_ephemeral("temp2"));
758        s.push_message(MessageRole::User, "keep2");
759        assert_eq!(s.chat_messages().len(), 4);
760        s.remove_ephemeral_messages();
761        assert_eq!(s.chat_messages().len(), 2);
762        assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
763    }
764
765    #[test]
766    fn test_close_dangling_tool_calls_noop_without_tool_call() {
767        let mut s = make_session();
768        s.push_message(MessageRole::User, "hi");
769        s.push_message(MessageRole::Assistant, "hi");
770        s.close_dangling_tool_calls("failed");
771        assert_eq!(s.chat_messages().len(), 2);
772    }
773
774    #[test]
775    fn test_close_dangling_tool_calls_adds_missing_results() {
776        let mut s = make_session();
777        s.push_message(MessageRole::User, "do");
778        s.push_assistant_tool_calls(
779            &[
780                ("c1".into(), "t".into(), "{}".into()),
781                ("c2".into(), "t".into(), "{}".into()),
782            ],
783            None,
784            None,
785        );
786        s.push_tool_result("c1", "ok"); // only c1 answered
787        s.close_dangling_tool_calls("failed");
788
789        let tool_results: Vec<(String, String)> = s
790            .chat_messages()
791            .iter()
792            .filter_map(|m| match m {
793                ChatMessage::Tool {
794                    tool_call_id,
795                    name: _,
796                    content,
797                } => Some((tool_call_id.clone(), content.clone())),
798                _ => None,
799            })
800            .collect();
801        assert_eq!(tool_results.len(), 2);
802        assert!(
803            tool_results
804                .iter()
805                .any(|(id, c)| id == "c2" && c == "failed")
806        );
807    }
808
809    #[test]
810    fn test_set_chat_messages_recalculates_total_tool_calls() {
811        let mut s = make_session();
812        let msgs = vec![
813            ChatMessage::user("do"),
814            ChatMessage::assistant_tool_call("c1", "t", "{}"),
815            ChatMessage::tool("c1", "result"),
816        ];
817        s.set_chat_messages(msgs).unwrap();
818        assert_eq!(s.total_tool_calls, 1);
819    }
820}
821
822#[cfg(test)]
823mod validate_tests {
824    use super::*;
825
826    #[test]
827    fn test_valid_simple_sequence() {
828        let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
829        assert!(validate_message_sequence(&msgs).is_ok());
830    }
831
832    #[test]
833    fn test_valid_tool_call_sequence() {
834        let msgs = vec![
835            ChatMessage::user("run command"),
836            ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
837            ChatMessage::tool("call_1", "file1 file2"),
838            ChatMessage::assistant("done"),
839        ];
840        assert!(validate_message_sequence(&msgs).is_ok());
841    }
842
843    #[test]
844    fn test_valid_multi_tool_call_sequence() {
845        let msgs = vec![
846            ChatMessage::user("run commands"),
847            ChatMessage::Assistant {
848                content: None,
849                reasoning_content: None,
850                tool_calls: Some(vec![
851                    crate::types::ToolCallMessage {
852                        id: "call_1".into(),
853                        name: "bash".into(),
854                        arguments: "{}".into(),
855                    },
856                    crate::types::ToolCallMessage {
857                        id: "call_2".into(),
858                        name: "read".into(),
859                        arguments: "{}".into(),
860                    },
861                ]),
862                thinking_signature: None,
863            },
864            ChatMessage::tool("call_1", "result1"),
865            ChatMessage::tool("call_2", "result2"),
866            ChatMessage::assistant("done"),
867        ];
868        assert!(validate_message_sequence(&msgs).is_ok());
869    }
870
871    #[test]
872    fn test_orphaned_tool_result() {
873        let msgs = vec![
874            ChatMessage::user("hello"),
875            ChatMessage::tool("call_1", "orphaned result"),
876        ];
877        let err = validate_message_sequence(&msgs).unwrap_err();
878        assert!(err.contains("no preceding tool_call"));
879    }
880
881    #[test]
882    fn test_system_only_sequence_rejected() {
883        // A compaction that leaves only System/Custom messages would send an
884        // empty `messages` array (System maps to the `system` param) — the
885        // contract rejects it.
886        let msgs = vec![
887            ChatMessage::system("prompt"),
888            ChatMessage::system_ephemeral("reminder"),
889            ChatMessage::Custom {
890                role: "artifact".into(),
891                data: serde_json::json!({"id": "x"}),
892            },
893        ];
894        let err = validate_message_sequence(&msgs).unwrap_err();
895        assert!(err.contains("no sendable message"));
896    }
897
898    #[test]
899    fn test_system_plus_user_ok() {
900        let msgs = vec![ChatMessage::system("prompt"), ChatMessage::user("hi")];
901        assert!(validate_message_sequence(&msgs).is_ok());
902    }
903
904    #[test]
905    fn test_mismatched_tool_call_id() {
906        let msgs = vec![
907            ChatMessage::user("run"),
908            ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
909            ChatMessage::tool("call_2", "wrong id"),
910        ];
911        let err = validate_message_sequence(&msgs).unwrap_err();
912        assert!(err.contains("does not match"));
913    }
914
915    #[test]
916    fn test_set_chat_messages_valid() {
917        let mut s = make_session();
918        let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
919        assert!(s.set_chat_messages(msgs.clone()).is_ok());
920        assert_eq!(s.chat_messages().len(), 2);
921    }
922
923    #[test]
924    fn test_set_chat_messages_invalid() {
925        let mut s = make_session();
926        let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
927        assert!(s.set_chat_messages(msgs).is_err());
928    }
929
930    // ── RunState tests ────────────────────────────────────────────────────
931
932    #[test]
933    fn run_state_default() {
934        let rs = RunState::default();
935        assert_eq!(rs.turn_tool_calls, 0);
936        assert!(!rs.run_has_tool_calls);
937        assert_eq!(rs.reasoning_only_strikes, 0);
938        assert_eq!(rs.empty_response_strikes, 0);
939        assert_eq!(rs.nudge_count, 0);
940    }
941
942    #[test]
943    fn run_state_reset_for_new_run() {
944        let mut rs = RunState {
945            turn_tool_calls: 5,
946            run_has_tool_calls: true,
947            reasoning_only_strikes: 2,
948            empty_response_strikes: 1,
949            nudge_count: 3,
950            thinking_disabled_for_rest_of_run: true,
951            original_thinking_enabled: true,
952            truncation_strikes: 4,
953        };
954
955        rs.reset_for_new_run();
956
957        assert_eq!(rs.turn_tool_calls, 0);
958        assert!(!rs.run_has_tool_calls);
959        assert_eq!(rs.reasoning_only_strikes, 0);
960        assert_eq!(rs.empty_response_strikes, 0);
961        assert_eq!(rs.nudge_count, 0);
962        assert_eq!(rs.truncation_strikes, 0);
963        assert!(!rs.thinking_disabled_for_rest_of_run);
964        // original_thinking_enabled is NOT reset
965        assert!(rs.original_thinking_enabled);
966    }
967
968    #[test]
969    fn run_state_record_tool_calls() {
970        let mut rs = RunState {
971            reasoning_only_strikes: 2,
972            empty_response_strikes: 1,
973            ..RunState::default()
974        };
975
976        rs.record_tool_calls(3);
977
978        assert_eq!(rs.turn_tool_calls, 3);
979        assert!(rs.run_has_tool_calls);
980        assert_eq!(rs.reasoning_only_strikes, 0); // reset
981        assert_eq!(rs.empty_response_strikes, 0); // reset
982    }
983
984    #[test]
985    fn run_state_record_tool_calls_accumulates() {
986        let mut rs = RunState::default();
987        rs.record_tool_calls(2);
988        rs.record_tool_calls(3);
989
990        assert_eq!(rs.turn_tool_calls, 5);
991        assert!(rs.run_has_tool_calls);
992    }
993
994    #[test]
995    fn run_state_record_reasoning_only() {
996        let mut rs = RunState {
997            empty_response_strikes: 2,
998            ..RunState::default()
999        };
1000
1001        let strikes = rs.record_reasoning_only();
1002
1003        assert_eq!(strikes, 1);
1004        assert_eq!(rs.reasoning_only_strikes, 1);
1005        assert_eq!(rs.empty_response_strikes, 0); // reset
1006    }
1007
1008    #[test]
1009    fn run_state_record_reasoning_only_consecutive() {
1010        let mut rs = RunState::default();
1011
1012        assert_eq!(rs.record_reasoning_only(), 1);
1013        assert_eq!(rs.record_reasoning_only(), 2);
1014        assert_eq!(rs.record_reasoning_only(), 3);
1015    }
1016
1017    #[test]
1018    fn run_state_record_empty_response() {
1019        let mut rs = RunState {
1020            reasoning_only_strikes: 2,
1021            ..RunState::default()
1022        };
1023
1024        let strikes = rs.record_empty_response();
1025
1026        assert_eq!(strikes, 1);
1027        assert_eq!(rs.empty_response_strikes, 1);
1028        assert_eq!(rs.reasoning_only_strikes, 0); // reset
1029    }
1030
1031    #[test]
1032    fn run_state_record_empty_response_consecutive() {
1033        let mut rs = RunState::default();
1034
1035        assert_eq!(rs.record_empty_response(), 1);
1036        assert_eq!(rs.record_empty_response(), 2);
1037        assert_eq!(rs.record_empty_response(), 3);
1038    }
1039
1040    #[test]
1041    fn run_state_branch_cross_reset() {
1042        // Simulate: reasoning only → tool calls → reasoning only
1043        let mut rs = RunState::default();
1044
1045        // Branch 1: reasoning only
1046        rs.record_reasoning_only();
1047        assert_eq!(rs.reasoning_only_strikes, 1);
1048
1049        // Branch 3: tool calls (should reset reasoning_only_strikes)
1050        rs.record_tool_calls(2);
1051        assert_eq!(rs.reasoning_only_strikes, 0);
1052        assert_eq!(rs.turn_tool_calls, 2);
1053
1054        // Branch 1 again: reasoning only (should start from 1, not 2)
1055        let strikes = rs.record_reasoning_only();
1056        assert_eq!(strikes, 1);
1057    }
1058
1059    #[test]
1060    fn run_state_empty_to_reasoning_reset() {
1061        // Simulate: empty → empty → reasoning only (should reset empty strikes)
1062        let mut rs = RunState::default();
1063
1064        rs.record_empty_response();
1065        rs.record_empty_response();
1066        assert_eq!(rs.empty_response_strikes, 2);
1067
1068        // Branch 1: reasoning only (should reset empty_response_strikes)
1069        rs.record_reasoning_only();
1070        assert_eq!(rs.empty_response_strikes, 0);
1071        assert_eq!(rs.reasoning_only_strikes, 1);
1072    }
1073
1074    #[test]
1075    fn run_state_thinking_disabled_default() {
1076        let rs = RunState::default();
1077        assert!(!rs.thinking_disabled_for_rest_of_run);
1078    }
1079
1080    #[test]
1081    fn run_state_thinking_disabled_after_3_strikes() {
1082        let mut rs = RunState::default();
1083
1084        // After 1st reasoning-only: not disabled
1085        rs.record_reasoning_only();
1086        assert!(!rs.thinking_disabled_for_rest_of_run);
1087        assert_eq!(rs.reasoning_only_strikes, 1);
1088
1089        // After 2nd reasoning-only: not disabled
1090        rs.record_reasoning_only();
1091        assert!(!rs.thinking_disabled_for_rest_of_run);
1092        assert_eq!(rs.reasoning_only_strikes, 2);
1093
1094        // After 3rd reasoning-only: disabled!
1095        rs.record_reasoning_only();
1096        assert!(rs.thinking_disabled_for_rest_of_run);
1097        assert_eq!(rs.reasoning_only_strikes, 3);
1098    }
1099
1100    #[test]
1101    fn run_state_thinking_disabled_resets_on_new_run() {
1102        let mut rs = RunState::default();
1103
1104        // Simulate 3 reasoning-only responses
1105        rs.record_reasoning_only();
1106        rs.record_reasoning_only();
1107        rs.record_reasoning_only();
1108        assert!(rs.thinking_disabled_for_rest_of_run);
1109        assert_eq!(rs.reasoning_only_strikes, 3);
1110
1111        // Reset for new run (new user message)
1112        rs.reset_for_new_run();
1113        assert!(!rs.thinking_disabled_for_rest_of_run);
1114        assert_eq!(rs.reasoning_only_strikes, 0);
1115    }
1116
1117    #[test]
1118    fn run_state_thinking_disabled_stays_after_tool_calls() {
1119        let mut rs = RunState::default();
1120
1121        // Simulate 3 reasoning-only responses
1122        rs.record_reasoning_only();
1123        rs.record_reasoning_only();
1124        rs.record_reasoning_only();
1125        assert!(rs.thinking_disabled_for_rest_of_run);
1126
1127        // Tool calls should NOT reset thinking_disabled_for_rest_of_run
1128        // (it should stay disabled for the rest of the run)
1129        rs.record_tool_calls(2);
1130        assert!(rs.thinking_disabled_for_rest_of_run);
1131        assert_eq!(rs.reasoning_only_strikes, 0); // strikes reset, but thinking stays disabled
1132    }
1133
1134    // ── Backward-compatible deserialization ────────────────────────────────
1135
1136    #[test]
1137    fn deserialize_legacy_flat_fields() {
1138        // Old format: nudge_count, turn_tool_calls, etc. as flat fields
1139        let json = r#"{
1140            "id": null,
1141            "chat_messages": [],
1142            "always_allowed_actions": [],
1143            "total_tool_calls": 5,
1144            "nudge_count": 3,
1145            "turn_tool_calls": 2,
1146            "reasoning_only_strikes": 1,
1147            "empty_response_strikes": 0
1148        }"#;
1149        let session: AgentSession = serde_json::from_str(json).unwrap();
1150        assert_eq!(session.run_state.nudge_count, 3);
1151        assert_eq!(session.run_state.turn_tool_calls, 2);
1152        assert_eq!(session.run_state.reasoning_only_strikes, 1);
1153        assert_eq!(session.run_state.empty_response_strikes, 0);
1154        assert!(!session.run_state.run_has_tool_calls); // default
1155    }
1156
1157    #[test]
1158    fn deserialize_new_run_state_format() {
1159        // New format: nested run_state
1160        let json = r#"{
1161            "id": null,
1162            "chat_messages": [],
1163            "always_allowed_actions": [],
1164            "total_tool_calls": 5,
1165            "run_state": {
1166                "turn_tool_calls": 4,
1167                "run_has_tool_calls": true,
1168                "reasoning_only_strikes": 0,
1169                "empty_response_strikes": 1,
1170                "nudge_count": 2
1171            }
1172        }"#;
1173        let session: AgentSession = serde_json::from_str(json).unwrap();
1174        assert_eq!(session.run_state.turn_tool_calls, 4);
1175        assert!(session.run_state.run_has_tool_calls);
1176        assert_eq!(session.run_state.empty_response_strikes, 1);
1177        assert_eq!(session.run_state.nudge_count, 2);
1178    }
1179
1180    #[test]
1181    fn deserialize_run_state_takes_precedence_over_flat() {
1182        // When both are present, run_state wins
1183        let json = r#"{
1184            "id": null,
1185            "chat_messages": [],
1186            "always_allowed_actions": [],
1187            "total_tool_calls": 0,
1188            "run_state": {
1189                "turn_tool_calls": 10,
1190                "run_has_tool_calls": true,
1191                "reasoning_only_strikes": 0,
1192                "empty_response_strikes": 0,
1193                "nudge_count": 0
1194            },
1195            "nudge_count": 99,
1196            "turn_tool_calls": 99
1197        }"#;
1198        let session: AgentSession = serde_json::from_str(json).unwrap();
1199        assert_eq!(session.run_state.turn_tool_calls, 10); // run_state wins
1200        assert_eq!(session.run_state.nudge_count, 0); // run_state wins
1201    }
1202
1203    #[test]
1204    fn deserialize_legacy_missing_optional_fields() {
1205        // Old format with some fields missing (defaults to 0)
1206        let json = r#"{
1207            "id": null,
1208            "chat_messages": [],
1209            "always_allowed_actions": [],
1210            "total_tool_calls": 0,
1211            "nudge_count": 1
1212        }"#;
1213        let session: AgentSession = serde_json::from_str(json).unwrap();
1214        assert_eq!(session.run_state.nudge_count, 1);
1215        assert_eq!(session.run_state.turn_tool_calls, 0); // missing → 0
1216        assert_eq!(session.run_state.reasoning_only_strikes, 0);
1217        assert_eq!(session.run_state.empty_response_strikes, 0);
1218    }
1219
1220    #[test]
1221    fn roundtrip_preserves_run_state() {
1222        let mut session = AgentSession::new(SessionId::new(1));
1223        session.run_state.nudge_count = 5;
1224        session.run_state.turn_tool_calls = 3;
1225        session.run_state.run_has_tool_calls = true;
1226        session.run_state.reasoning_only_strikes = 2;
1227
1228        let json = serde_json::to_string(&session).unwrap();
1229        let restored: AgentSession = serde_json::from_str(&json).unwrap();
1230        assert_eq!(restored.run_state.nudge_count, 5);
1231        assert_eq!(restored.run_state.turn_tool_calls, 3);
1232        assert!(restored.run_state.run_has_tool_calls);
1233        assert_eq!(restored.run_state.reasoning_only_strikes, 2);
1234    }
1235
1236    #[test]
1237    fn push_assistant_tool_calls_validates_json_args() {
1238        let mut s = make_session();
1239
1240        // Valid JSON args should pass through unchanged
1241        let valid_args = r#"{"path": "src/main.rs", "content": "fn main() {}"}"#;
1242        s.push_assistant_tool_calls(
1243            &[("id1".into(), "write_file".into(), valid_args.into())],
1244            None,
1245            None,
1246        );
1247        if let ChatMessage::Assistant {
1248            tool_calls: Some(ref tc),
1249            ..
1250        } = s.chat_messages[0]
1251        {
1252            assert_eq!(tc[0].arguments, valid_args);
1253        } else {
1254            panic!("expected Assistant message with tool_calls");
1255        }
1256
1257        // Truncated (invalid JSON) args must be sanitized to an empty object,
1258        // NOT wrapped in a descriptive error object. Two reasons:
1259        //   1. "{}" is valid JSON, so the next request is never rejected 400.
1260        //   2. a rich `{error, original_args_preview, message}` object in
1261        //      assistant history is an imitation vector — the model replays it
1262        //      verbatim as the next call's arguments, and because it parses as
1263        //      valid JSON it slips past the react truncation guard and resurfaces
1264        //      as a downstream ToolArgsInvalid. The truncation explanation
1265        //      belongs in the tool_result, not the assistant arguments.
1266        let truncated_args = r#"{"path": "src/ui/markdown.rs", "content": "#;
1267        s.push_assistant_tool_calls(
1268            &[("id2".into(), "write_file".into(), truncated_args.into())],
1269            None,
1270            None,
1271        );
1272        if let ChatMessage::Assistant {
1273            tool_calls: Some(ref tc),
1274            ..
1275        } = s.chat_messages[1]
1276        {
1277            assert_eq!(tc[0].arguments, "{}");
1278            assert!(
1279                !tc[0].arguments.contains("tool_call_arguments_truncated"),
1280                "assistant arguments must not carry the poison wrapper object"
1281            );
1282        } else {
1283            panic!("expected Assistant message with tool_calls");
1284        }
1285    }
1286
1287    #[test]
1288    fn push_assistant_tool_calls_truncated_multibyte_no_panic() {
1289        // Bug-2: Invalid JSON args with multi-byte UTF-8 chars near byte 200
1290        // cause a panic at char boundary when slicing &args[..args.len().min(200)].
1291        let mut s = make_session();
1292
1293        // Build invalid JSON with CJK chars that straddle the 200-byte boundary.
1294        // "あ" = 3 bytes in UTF-8. Repeating ~70 times = ~210 bytes, then add invalid suffix.
1295        let mut bad_args = "あ".repeat(70); // 70 * 3 = 210 bytes
1296        bad_args.push_str("truncated"); // makes it invalid JSON
1297
1298        // This must NOT panic. Invalid args are sanitized to "{}" (a fixed
1299        // literal), so no slicing of the multibyte string happens at all.
1300        s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), bad_args)], None, None);
1301
1302        if let ChatMessage::Assistant {
1303            tool_calls: Some(ref tc),
1304            ..
1305        } = s.chat_messages[0]
1306        {
1307            assert_eq!(tc[0].arguments, "{}");
1308        } else {
1309            panic!("expected Assistant message with tool_calls");
1310        }
1311    }
1312
1313    #[test]
1314    fn push_assistant_tool_calls_then_tool_result_matches_anthropic_protocol() {
1315        // Regression test for: when LLM response is truncated (finish_reason=max_tokens),
1316        // the code must push assistant message WITH tool_use blocks (not plain text)
1317        // so that subsequent tool_result messages can match the tool_use_id.
1318        // This is required by Anthropic protocol.
1319        let mut s = make_session();
1320
1321        // Simulate truncated tool call response
1322        let tool_calls = vec![
1323            (
1324                "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883".to_string(),
1325                "write_file".to_string(),
1326                "{}".to_string(),
1327            ),
1328            (
1329                "call_01_abc123".to_string(),
1330                "bash".to_string(),
1331                r#"{"command": "ls"}"#.to_string(),
1332            ),
1333        ];
1334
1335        // Push assistant message with tool_calls (as the fix does)
1336        s.push_assistant_tool_calls(
1337            &tool_calls,
1338            Some("thinking...".to_string()),
1339            Some("I'll help you".to_string()),
1340        );
1341
1342        // Push tool results for each tool call
1343        for (tc_id, _, _) in &tool_calls {
1344            s.push_tool_result(
1345                tc_id,
1346                "Tool call was not executed: the response hit the output token limit.",
1347            );
1348        }
1349
1350        // Verify the message sequence is valid
1351        // 1. Assistant message should have tool_calls
1352        if let ChatMessage::Assistant {
1353            tool_calls: Some(ref tc),
1354            ..
1355        } = s.chat_messages[0]
1356        {
1357            assert_eq!(tc.len(), 2);
1358            assert_eq!(tc[0].id, "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883");
1359            assert_eq!(tc[0].name, "write_file");
1360            assert_eq!(tc[1].id, "call_01_abc123");
1361            assert_eq!(tc[1].name, "bash");
1362        } else {
1363            panic!("expected Assistant message with tool_calls");
1364        }
1365
1366        // 2. Tool result messages should have matching tool_use_id
1367        if let ChatMessage::Tool { tool_call_id, .. } = &s.chat_messages[1] {
1368            assert_eq!(tool_call_id, "call_00_VJtlnKha0ZZ2Yo8t5ysQ8883");
1369        } else {
1370            panic!("expected Tool message for first tool call");
1371        }
1372
1373        if let ChatMessage::Tool { tool_call_id, .. } = &s.chat_messages[2] {
1374            assert_eq!(tool_call_id, "call_01_abc123");
1375        } else {
1376            panic!("expected Tool message for second tool call");
1377        }
1378
1379        // 3. Validate message sequence (this would catch the bug)
1380        assert!(
1381            validate_message_sequence(&s.chat_messages).is_ok(),
1382            "message sequence should be valid with matching tool_use and tool_result"
1383        );
1384    }
1385}
1386
1387#[cfg(test)]
1388mod proptest_tests {
1389    use super::*;
1390    use proptest::prelude::*;
1391
1392    proptest! {
1393        // ── RunState property tests ─────────────────────────────────────────
1394
1395        #[test]
1396        fn reset_for_new_run_zeros_all_fields(
1397            turn_tool_calls in 0usize..1000,
1398            run_has_tool_calls in proptest::bool::ANY,
1399            reasoning_only_strikes in 0usize..100,
1400            empty_response_strikes in 0usize..100,
1401            nudge_count in 0usize..100,
1402        ) {
1403            let mut rs = RunState {
1404                turn_tool_calls,
1405                run_has_tool_calls,
1406                reasoning_only_strikes,
1407                empty_response_strikes,
1408                nudge_count,
1409                thinking_disabled_for_rest_of_run: true,
1410                original_thinking_enabled: true,
1411                truncation_strikes: 5,
1412            };
1413            rs.reset_for_new_run();
1414            assert_eq!(rs.turn_tool_calls, 0);
1415            assert!(!rs.run_has_tool_calls);
1416            assert_eq!(rs.reasoning_only_strikes, 0);
1417            assert_eq!(rs.empty_response_strikes, 0);
1418            assert_eq!(rs.nudge_count, 0);
1419            assert_eq!(rs.truncation_strikes, 0);
1420            assert!(!rs.thinking_disabled_for_rest_of_run);
1421            // original_thinking_enabled is NOT reset
1422            assert!(rs.original_thinking_enabled);
1423        }
1424
1425        #[test]
1426        fn record_tool_calls_accumulates(n in 0usize..100) {
1427            let mut rs = RunState::default();
1428            rs.record_tool_calls(n);
1429            assert_eq!(rs.turn_tool_calls, n);
1430            assert!(rs.run_has_tool_calls);
1431            assert_eq!(rs.reasoning_only_strikes, 0);
1432            assert_eq!(rs.empty_response_strikes, 0);
1433        }
1434
1435        #[test]
1436        fn record_reasoning_only_increments(count in 1usize..50) {
1437            let mut rs = RunState::default();
1438            for i in 1..=count {
1439                let strikes = rs.record_reasoning_only();
1440                assert_eq!(strikes, i);
1441                assert_eq!(rs.empty_response_strikes, 0);
1442            }
1443        }
1444
1445        #[test]
1446        fn record_empty_response_increments(count in 1usize..50) {
1447            let mut rs = RunState::default();
1448            for i in 1..=count {
1449                let strikes = rs.record_empty_response();
1450                assert_eq!(strikes, i);
1451                assert_eq!(rs.reasoning_only_strikes, 0);
1452            }
1453        }
1454
1455        // ── push_assistant_tool_calls property tests ────────────────────────
1456
1457        #[test]
1458        fn push_assistant_tool_calls_valid_json_unchanged(args in r"\{[^{}]{0,200}\}") {
1459            // Only test strings that are actually valid JSON objects
1460            if serde_json::from_str::<serde_json::Value>(&args).is_err() {
1461                return Ok(());
1462            }
1463            let mut s = make_session();
1464            s.push_assistant_tool_calls(
1465                &[("id".into(), "tool".into(), args.clone())],
1466                None,
1467                None,
1468            );
1469            if let ChatMessage::Assistant { tool_calls: Some(ref tc), .. } = s.chat_messages()[0] {
1470                assert_eq!(tc[0].arguments, args);
1471            } else {
1472                panic!("expected Assistant with tool_calls");
1473            }
1474        }
1475
1476        #[test]
1477        fn push_assistant_tool_calls_invalid_json_sanitized_to_empty(
1478            bad_args in "[a-z\u{4e00}-\u{9fff}]{0,300}"
1479        ) {
1480            // Skip if it happens to be valid JSON
1481            if serde_json::from_str::<serde_json::Value>(&bad_args).is_ok() {
1482                return Ok(());
1483            }
1484            let mut s = make_session();
1485            s.push_assistant_tool_calls(
1486                &[("id".into(), "tool".into(), bad_args)],
1487                None,
1488                None,
1489            );
1490            if let ChatMessage::Assistant { tool_calls: Some(ref tc), .. } = s.chat_messages()[0] {
1491                // Must be valid JSON (no 400 on replay) AND carry no poison
1492                // wrapper the model could echo back as arguments.
1493                serde_json::from_str::<serde_json::Value>(&tc[0].arguments)
1494                    .expect("sanitized args must be valid JSON");
1495                assert_eq!(tc[0].arguments, "{}");
1496            } else {
1497                panic!("expected Assistant with tool_calls");
1498            }
1499        }
1500
1501        // ── trim_oldest_turns property tests ────────────────────────────────
1502
1503        #[test]
1504        fn trim_oldest_turns_never_exceeds_max(turns in 1usize..20, max in 1usize..20) {
1505            let mut s = make_session();
1506            for i in 0..turns {
1507                s.push_message(MessageRole::User, format!("u{}", i));
1508                s.push_message(MessageRole::Assistant, format!("a{}", i));
1509            }
1510            s.trim_oldest_turns(max);
1511            assert!(s.turn_count() <= max || turns <= max);
1512        }
1513
1514        // ── validate_message_sequence property tests ────────────────────────
1515
1516        #[test]
1517        fn validate_simple_user_assistant_always_passes(count in 1usize..20) {
1518            let mut msgs = Vec::new();
1519            for i in 0..count {
1520                msgs.push(ChatMessage::user(format!("msg{}", i)));
1521                msgs.push(ChatMessage::assistant(format!("reply{}", i)));
1522            }
1523            assert!(validate_message_sequence(&msgs).is_ok());
1524        }
1525    }
1526}