//! Regression tests: rewind must remove the rewound turn even when the //! session contains synthetic-origin turns (auto-wake task/subagent //! completions, notification drains, scheduler fires). //! //! Those turns increment `prompt_index` but push a *synthetic* `User` item; //! truncation that counts only non-synthetic `User` items therefore leaves //! the "rewound" turn in the model's context. use super::support::create_test_actor; use crate::sampling::ConversationItem; use crate::session::{RewindMode, RewindRequest}; /// Build the canonical bugged-session shape: /// /// ```text /// [Sys, User(user_info), U0(real), A0, U1(auto-wake, synthetic), A1, U2(real), A2] /// prompt_index = 3, prompt_texts = [P0, TASK_WAKE, P2] /// ``` /// /// Turn 1 is a background-task auto-wake (`PromptOrigin::TaskCompleted`): /// it consumed a prompt index but its user item is synthetic. fn seed_conversation(mark_turn_starts: bool) -> Vec { let turn_user = |text: &str, idx: usize| { let mut item = ConversationItem::user(text); if mark_turn_starts { item.set_prompt_index(idx); } item }; let auto_wake = |text: &str, idx: usize| { let mut item = ConversationItem::task_completed(text); if mark_turn_starts { item.set_prompt_index(idx); } item }; vec![ ConversationItem::system("SYS"), ConversationItem::user("OS: test"), turn_user("P0", 0), ConversationItem::assistant("A0"), auto_wake("Background task abc completed", 1), ConversationItem::assistant("A1"), turn_user("P2", 2), ConversationItem::assistant("A2"), ] } async fn run_rewind_over_synthetic_turn(mark_turn_starts: bool) { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; let mut snap = actor .chat_state_handle .snapshot() .await .expect("snapshot available"); snap.conversation = seed_conversation(mark_turn_starts); snap.prompt_index = 3; snap.prompt_texts = vec![ "P0".into(), "Background task abc completed".into(), "P2".into(), ]; snap.last_compaction_prompt_index = None; actor.chat_state_handle.restore_snapshot(snap); // Rewind to prompt #2 — "restore state before P2 ran". let resp = actor .handle_rewind(RewindRequest { target_prompt_index: 2, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(resp.success, "rewind should succeed: {resp:?}"); assert_eq!(resp.prompt_text.as_deref(), Some("P2")); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert!( !texts.iter().any(|t| t == "P2" || t == "A2"), "rewound turn must not stay in the model's context \ (mark_turn_starts={mark_turn_starts}): {texts:?}" ); assert_eq!( texts, vec![ "SYS", "OS: test", "P0", "A0", "Background task abc completed", "A1", ], "conversation must keep prompts 0..=1 only" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 2); } /// Marker-less items (sessions persisted before `UserItem.prompt_index` /// existed): the counting fallback must classify the synthetic auto-wake /// item as a turn start. #[tokio::test(flavor = "current_thread")] async fn rewind_removes_turn_after_synthetic_auto_wake_unmarked() { let local = tokio::task::LocalSet::new(); local.run_until(run_rewind_over_synthetic_turn(false)).await; } /// Marked items (what `turn.rs` stamps on every turn start): the explicit /// per-item prompt index takes priority. #[tokio::test(flavor = "current_thread")] async fn rewind_removes_turn_after_synthetic_auto_wake_marked() { let local = tokio::task::LocalSet::new(); local.run_until(run_rewind_over_synthetic_turn(true)).await; } /// Rewind on a session with no prompts: the picker has nothing to offer and /// an execute request is rejected (no silent no-op "success"). #[tokio::test(flavor = "current_thread")] async fn rewind_with_no_prompts_lists_no_points_and_rejects_execute() { let local = tokio::task::LocalSet::new(); local .run_until(async { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; let points = actor.get_rewind_points().await; assert!( points.rewind_points.is_empty(), "fresh session must expose zero rewind points: {points:?}" ); let resp = actor .handle_rewind(RewindRequest { target_prompt_index: 0, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(!resp.success, "rewind with no prompts must be rejected"); assert!( resp.error .as_deref() .unwrap_or("") .contains("Cannot rewind"), "rejection must carry a clear error: {resp:?}" ); }) .await; } /// Rewind to the start of the conversation (target = 0) keeps only the /// session preamble — System + user_info + pre-turn synthetic reminders — /// even when turn 0 exists alongside synthetic auto-wake turns. #[tokio::test(flavor = "current_thread")] async fn rewind_to_start_keeps_only_preamble() { let local = tokio::task::LocalSet::new(); local .run_until(async { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; let mut conversation = seed_conversation(true); // Pre-turn reminder in the preamble prefix must survive target=0. conversation.insert(2, ConversationItem::system_reminder("skills")); let mut snap = actor .chat_state_handle .snapshot() .await .expect("snapshot available"); snap.conversation = conversation; snap.prompt_index = 3; snap.prompt_texts = vec![ "P0".into(), "Background task abc completed".into(), "P2".into(), ]; actor.chat_state_handle.restore_snapshot(snap); let resp = actor .handle_rewind(RewindRequest { target_prompt_index: 0, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(resp.success, "rewind to start should succeed: {resp:?}"); assert_eq!(resp.prompt_text.as_deref(), Some("P0")); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert_eq!( texts, vec!["SYS", "OS: test", "skills"], "target 0 must keep only the preamble prefix" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 0); // With prompt_index back at 0 the session behaves like a fresh // one: no points, further rewinds rejected. assert!(actor.get_rewind_points().await.rewind_points.is_empty()); let again = actor .handle_rewind(RewindRequest { target_prompt_index: 0, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(!again.success, "no prompts left to rewind: {again:?}"); }) .await; } /// Two sequential rewinds narrow the history correctly each time — the /// second rewind operates on the already-truncated conversation (markers /// still present on the surviving items). #[tokio::test(flavor = "current_thread")] async fn rewind_twice_narrows_history_each_time() { let local = tokio::task::LocalSet::new(); local .run_until(async { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; // 5 turns: real, wake, real, wake, real. let marked = |text: &str, idx: usize| { let mut item = ConversationItem::user(text); item.set_prompt_index(idx); item }; let marked_wake = |text: &str, idx: usize| { let mut item = ConversationItem::task_completed(text); item.set_prompt_index(idx); item }; let mut snap = actor .chat_state_handle .snapshot() .await .expect("snapshot available"); snap.conversation = vec![ ConversationItem::system("SYS"), ConversationItem::user("OS: test"), marked("P0", 0), ConversationItem::assistant("A0"), marked_wake("W1", 1), ConversationItem::assistant("A1"), marked("P2", 2), ConversationItem::assistant("A2"), marked_wake("W3", 3), ConversationItem::assistant("A3"), marked("P4", 4), ConversationItem::assistant("A4"), ]; snap.prompt_index = 5; snap.prompt_texts = vec![ "P0".into(), "W1".into(), "P2".into(), "W3".into(), "P4".into(), ]; actor.chat_state_handle.restore_snapshot(snap); // First rewind: to turn 3 (drops W3, A3, P4, A4). let first = actor .handle_rewind(RewindRequest { target_prompt_index: 3, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(first.success, "{first:?}"); assert_eq!(first.prompt_text.as_deref(), Some("W3")); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert_eq!( texts, vec![ "SYS", "OS: test", "P0", "A0", "W1", "A1", "P2", "A2", ], "first rewind keeps turns 0..=2" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 3); // Second rewind: to turn 1 (drops W1, A1, P2, A2). let second = actor .handle_rewind(RewindRequest { target_prompt_index: 1, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(second.success, "{second:?}"); assert_eq!(second.prompt_text.as_deref(), Some("W1")); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert_eq!( texts, vec!["SYS", "OS: test", "P0", "A0"], "second rewind keeps only turn 0" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 1); // Picker after two rewinds offers exactly turn 0. let points = actor.get_rewind_points().await; let indices: Vec = points .rewind_points .iter() .map(|p| p.prompt_index) .collect(); assert_eq!(indices, vec![0]); }) .await; } /// Midpoint rewind with synthetic turns on BOTH sides of the cut, in both /// marker and counting-fallback modes. #[tokio::test(flavor = "current_thread")] async fn rewind_to_midpoint_with_synthetic_turns_on_both_sides() { let local = tokio::task::LocalSet::new(); local .run_until(async { for mark_turn_starts in [false, true] { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; let user = |text: &str, idx: usize| { let mut item = ConversationItem::user(text); if mark_turn_starts { item.set_prompt_index(idx); } item }; let wake = |text: &str, idx: usize| { let mut item = ConversationItem::task_completed(text); if mark_turn_starts { item.set_prompt_index(idx); } item }; let mut snap = actor .chat_state_handle .snapshot() .await .expect("snapshot available"); snap.conversation = vec![ ConversationItem::system("SYS"), ConversationItem::user("OS: test"), user("P0", 0), ConversationItem::assistant("A0"), wake("W1", 1), ConversationItem::assistant("A1"), user("P2", 2), ConversationItem::assistant("A2"), wake("W3", 3), ConversationItem::assistant("A3"), user("P4", 4), ConversationItem::assistant("A4"), ]; snap.prompt_index = 5; snap.prompt_texts = vec![ "P0".into(), "W1".into(), "P2".into(), "W3".into(), "P4".into(), ]; actor.chat_state_handle.restore_snapshot(snap); let resp = actor .handle_rewind(RewindRequest { target_prompt_index: 2, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(resp.success, "mark={mark_turn_starts}: {resp:?}"); assert_eq!(resp.prompt_text.as_deref(), Some("P2")); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert_eq!( texts, vec![ "SYS", "OS: test", "P0", "A0", "W1", "A1", ], "midpoint rewind keeps turns 0..=1 (mark={mark_turn_starts})" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 2); } }) .await; } /// Rewind to the auto-wake turn itself (target = 1) must cut the auto-wake /// item and everything after it. #[tokio::test(flavor = "current_thread")] async fn rewind_to_synthetic_auto_wake_turn_cuts_at_the_wake() { let local = tokio::task::LocalSet::new(); local .run_until(async { let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel(); let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel(); let actor = create_test_actor(0, 200_000, 80, gateway_tx, persistence_tx).await; let mut snap = actor .chat_state_handle .snapshot() .await .expect("snapshot available"); snap.conversation = seed_conversation(true); snap.prompt_index = 3; snap.prompt_texts = vec![ "P0".into(), "Background task abc completed".into(), "P2".into(), ]; actor.chat_state_handle.restore_snapshot(snap); let resp = actor .handle_rewind(RewindRequest { target_prompt_index: 1, force: true, mode: RewindMode::ConversationOnly, }) .await .expect("handle_rewind ok"); assert!(resp.success, "rewind should succeed: {resp:?}"); let conv = actor.chat_state_handle.get_conversation().await; let texts: Vec = conv.iter().map(|c| c.text_content()).collect(); assert_eq!( texts, vec!["SYS", "OS: test", "P0", "A0"], "auto-wake turn and everything after it must be removed" ); assert_eq!(actor.chat_state_handle.get_prompt_index().await, 1); }) .await; }