//! Tests for ChatStateActor. use std::num::NonZeroU64; use std::time::Duration; use kigi_sampling_types::{ConversationItem, SamplingConfig}; use tokio::sync::mpsc; use crate::actor::ChatStateActor; use crate::events::ChatStateEvent; use crate::persistence::{MockChatPersistence, MockPersistenceReceiver, PersistenceRecord}; /// Helper to build a `SamplingConfig` for tests. fn test_config() -> SamplingConfig { test_config_with_window(128_000) } fn test_config_with_window(context_window: u64) -> SamplingConfig { SamplingConfig { base_url: "https://api.example.com".to_string(), model: "test-model".to_string(), max_completion_tokens: None, temperature: None, top_p: None, api_backend: Default::default(), chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(context_window) .expect("test context_window must be non-zero"), reasoning_effort: None, stream_tool_calls: None, } } /// Test harness that spawns an actor and keeps the event + persistence channels. struct TestHarness { handle: crate::handle::ChatStateHandle, event_rx: mpsc::UnboundedReceiver, persistence_rx: MockPersistenceReceiver, _cancellation_token: tokio_util::sync::CancellationToken, } impl TestHarness { fn new() -> Self { Self::with_config(vec![], test_config()) } fn with_conversation(items: Vec) -> Self { Self::with_config(items, test_config()) } fn with_context_window(window: u64) -> Self { Self::with_config(vec![], test_config_with_window(window)) } fn with_config(items: Vec, config: SamplingConfig) -> Self { let (mock, persistence_rx) = MockChatPersistence::new(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let handle = ChatStateActor::spawn(items, config, Box::new(mock), event_tx, token.clone()); Self { handle, event_rx, persistence_rx, _cancellation_token: token, } } /// Drain the next event from the event channel (with timeout). async fn next_event(&mut self) -> ChatStateEvent { tokio::time::timeout(Duration::from_secs(1), self.event_rx.recv()) .await .expect("timed out waiting for event") .expect("event channel closed") } /// Drain all pending events. fn drain_events(&mut self) -> Vec { let mut events = Vec::new(); while let Ok(event) = self.event_rx.try_recv() { events.push(event); } events } /// Drain all pending persistence records. fn drain_persistence(&mut self) -> Vec { self.persistence_rx.drain() } } // Lifecycle tests #[tokio::test] async fn actor_spawns_and_shuts_down_via_cancellation() { let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _event_rx) = mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let _handle = ChatStateActor::spawn( vec![], test_config(), Box::new(mock), event_tx, token.clone(), ); token.cancel(); tokio::time::sleep(Duration::from_millis(50)).await; } #[tokio::test] async fn actor_shuts_down_when_all_handles_dropped() { let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _event_rx) = mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let handle = ChatStateActor::spawn(vec![], test_config(), Box::new(mock), event_tx, token); drop(handle); tokio::time::sleep(Duration::from_millis(50)).await; } // Mutation tests #[tokio::test] async fn push_user_message_appends_and_persists() { let mut h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("hello")); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let records = h.drain_persistence(); assert_eq!(records.len(), 1); assert!(matches!(&records[0], PersistenceRecord::Message(_))); } #[tokio::test] async fn push_user_message_and_ack_waits_for_actor_acceptance() { let mut h = TestHarness::new(); let ack = h .handle .push_user_message_and_ack(ConversationItem::user("hello")) .await; assert!(ack.is_some()); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let records = h.drain_persistence(); assert_eq!(records.len(), 1); assert!(matches!(&records[0], PersistenceRecord::Message(_))); } #[tokio::test] async fn push_assistant_response_appends_and_persists() { let mut h = TestHarness::new(); h.handle .push_assistant_response(ConversationItem::assistant("hi")); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let records = h.drain_persistence(); assert_eq!(records.len(), 1); } #[tokio::test] async fn push_tool_result_appends_and_persists() { let mut h = TestHarness::new(); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "result")); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let records = h.drain_persistence(); assert_eq!(records.len(), 1); } #[tokio::test] async fn record_token_usage_emits_event() { let mut h = TestHarness::new(); h.handle.record_token_usage(1000); let event = h.next_event().await; assert!(matches!( event, ChatStateEvent::TokensUpdated { total_tokens: 1000 } )); let tokens = h.handle.get_total_tokens().await; assert_eq!(tokens, 1000); } #[tokio::test] async fn record_last_turn_usage_round_trip() { use kigi_sampling_types::TokenUsage; let h = TestHarness::new(); // Initial state: nothing stashed. assert!(h.handle.get_last_turn_usage().await.is_none()); let usage = TokenUsage { prompt_tokens: 1234, completion_tokens: 56, total_tokens: 1290, reasoning_tokens: 0, cached_prompt_tokens: 800, }; h.handle.record_last_turn_usage(usage.clone()); let got = h.handle.get_last_turn_usage().await.expect("usage stashed"); assert_eq!(got.prompt_tokens, 1234); assert_eq!(got.completion_tokens, 56); assert_eq!(got.cached_prompt_tokens, 800); // Subsequent record overwrites. let next = TokenUsage { prompt_tokens: 9999, completion_tokens: 1, total_tokens: 10000, reasoning_tokens: 0, cached_prompt_tokens: 0, }; h.handle.record_last_turn_usage(next); let got2 = h .handle .get_last_turn_usage() .await .expect("overwritten usage"); assert_eq!(got2.prompt_tokens, 9999); assert_eq!(got2.cached_prompt_tokens, 0); } #[tokio::test] async fn prompt_usage_ledger_via_handle_resets_and_clears() { use kigi_sampling_types::TokenUsage; let call = TokenUsage { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, reasoning_tokens: 0, cached_prompt_tokens: 0, }; let h = TestHarness::new(); h.handle .record_model_call_usage(Some("m".into()), call.clone(), None, None); h.handle .record_model_call_usage(None, call.clone(), None, None); assert_eq!( h.handle .try_get_prompt_usage() .await .unwrap() .expect("prompt ledger") .totals .model_calls, 2 ); h.handle.increment_prompt_index(); assert!( h.handle .try_get_prompt_usage() .await .ok() .flatten() .is_none() ); assert_eq!( h.handle .try_get_session_usage() .await .expect("actor alive") .totals .model_calls, 2 ); h.handle .record_model_call_usage(Some("m".into()), call.clone(), None, None); let snap = h.handle.snapshot().await.unwrap(); h.handle.restore_snapshot(snap); assert!( h.handle .try_get_prompt_usage() .await .ok() .flatten() .is_none() ); h.handle .record_model_call_usage(Some("m".into()), call, None, None); h.handle.truncate_to_prompt_index(0).await; assert!( h.handle .try_get_prompt_usage() .await .ok() .flatten() .is_none() ); } #[tokio::test] async fn estimated_tokens_tracks_tool_result_delta() { let h = TestHarness::new(); h.handle.record_token_usage(100_000); // Push a tool result with 4000 chars → ~1000 estimated tokens h.handle .push_tool_result(ConversationItem::tool_result("call-1", "x".repeat(4000))); let estimated = h.handle.get_estimated_total_tokens().await; // 100K model-reported + 1K delta assert_eq!(estimated, 101_000); // model-reported total_tokens is `unchanged` let actual = h.handle.get_total_tokens().await; assert_eq!(actual, 100_000); } #[tokio::test] async fn estimated_tokens_resets_on_model_response() { let h = TestHarness::new(); h.handle.record_token_usage(100_000); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "x".repeat(4000))); assert_eq!(h.handle.get_estimated_total_tokens().await, 101_000); // Model responds with a new total — delta resets h.handle.record_token_usage(105_000); assert_eq!(h.handle.get_estimated_total_tokens().await, 105_000); assert_eq!(h.handle.get_total_tokens().await, 105_000); } #[tokio::test] async fn estimated_tokens_tracks_synthetic_user_message_delta() { let h = TestHarness::new(); h.handle.record_token_usage(100_000); // Simulate a 4MB background-task completion notification pushed as a // synthetic User item between turns. h.handle.push_user_message(ConversationItem::user( "Background task completed:\n".to_string() + &"x".repeat(4_000_000), )); let estimated = h.handle.get_estimated_total_tokens().await; // 100K model-reported + ~1M tokens for the 4MB reminder. assert!( (1_099_000..=1_101_000).contains(&estimated), "expected ~1.1M tokens estimated, got {estimated}", ); // model-reported `total_tokens` is `unchanged` — only the delta moved. assert_eq!(h.handle.get_total_tokens().await, 100_000); } /// Regression: a normal user prompt pushed at turn start must increment /// the delta. The bump is voided when the model responds and /// `record_token_usage` includes the prompt in `usage.total_tokens`, /// keeping the post-response total accurate. #[tokio::test] async fn estimated_tokens_tracks_real_user_message_and_resets_on_response() { let h = TestHarness::new(); h.handle.record_token_usage(100_000); // Real user turn — 4000 chars / 4 = ~1000 tokens. h.handle .push_user_message(ConversationItem::user("u".repeat(4000))); let pre = h.handle.get_estimated_total_tokens().await; assert!( (101_000..=101_010).contains(&pre), "expected ~101K after user push, got {pre}", ); // Model responds; the API's `usage.total_tokens` already includes // the new user message. Delta must reset to zero. h.handle.record_token_usage(103_000); assert_eq!(h.handle.get_estimated_total_tokens().await, 103_000); assert_eq!(h.handle.get_total_tokens().await, 103_000); } /// Regression: pushing the assistant response back into the chat /// must NOT bump the delta — the model already counted it in /// `usage.completion_tokens` (which is folded into the just-applied /// `total_tokens`). Bumping again would double-count the assistant. #[tokio::test] async fn assistant_response_push_does_not_bump_estimated_delta() { let h = TestHarness::new(); h.handle.record_token_usage(100_000); h.handle .push_assistant_response(ConversationItem::assistant("a".repeat(4000))); let estimated = h.handle.get_estimated_total_tokens().await; assert_eq!( estimated, 100_000, "assistant push must not increment the delta (already in `usage.total_tokens`)" ); } #[tokio::test] async fn estimated_tokens_resets_on_truncate() { let mut h = TestHarness::new(); h.handle.record_token_usage(100_000); h.handle.push_user_message(ConversationItem::user("q1")); h.handle.increment_prompt_index(); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "x".repeat(4000))); assert_eq!(h.handle.get_estimated_total_tokens().await, 101_000); // Rewind removes the tool result — delta must reset h.drain_events(); h.handle.truncate_to_prompt_index(0).await; let post_rewind = h.handle.get_estimated_total_tokens().await; assert!( post_rewind < 100_000, "post-rewind tokens should reflect truncated conversation, not stale model count" ); } #[tokio::test] async fn increment_prompt_index_emits_event() { let mut h = TestHarness::new(); h.handle.increment_prompt_index(); let event = h.next_event().await; assert!(matches!( event, ChatStateEvent::PromptIndexChanged { new_index: 1 } )); let idx = h.handle.get_prompt_index().await; assert_eq!(idx, 1); } #[tokio::test] async fn replace_conversation_persists_and_emits_reset() { let mut h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("a")); h.handle.push_user_message(ConversationItem::user("b")); // Drain the two Message records let _ = h.handle.get_conversation().await; h.drain_persistence(); let new_items = vec![ConversationItem::system("compacted")]; h.handle.replace_conversation(new_items); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let records = h.drain_persistence(); assert_eq!(records.len(), 1); assert!(matches!(&records[0], PersistenceRecord::ReplaceHistory(_))); } #[tokio::test] async fn compaction_reseed_carries_provider_overhead() { let h = TestHarness::new(); // ~1k estimated tokens; provider reports 51k → 50k overhead. h.handle .push_user_message(ConversationItem::user("x".repeat(4000))); h.handle.record_token_usage(51_000); let compacted = vec![ ConversationItem::system("sys"), ConversationItem::user("summary ".repeat(500)), ]; h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; let estimate_only = 1_000u64; assert!( total > estimate_only * 10, "reseed must carry provider overhead, got {total}" ); assert!( total <= 51_000, "reseed must never exceed the pre-compaction provider total, got {total}" ); } #[tokio::test] async fn compaction_reseed_scales_overhead_down_with_deleted_content() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("x".repeat(160_000))); let estimate_at_response = crate::estimate_conversation_tokens(&h.handle.get_conversation().await); assert_eq!(estimate_at_response, 40_000); let provider_total = 87_000; h.handle.record_token_usage(provider_total); let compacted = vec![ConversationItem::user("z".repeat(12_000))]; let compacted_estimate = crate::estimate_conversation_tokens(&compacted); assert_eq!(compacted_estimate, 3_000); h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; assert_eq!(total, 6_525, "expected ratio-scaled overhead, got {total}"); let additive_model = compacted_estimate + (provider_total - estimate_at_response); assert_eq!(additive_model, 50_000); assert!( total < additive_model / 4, "ratio reseed ({total}) must be far below the additive over-count ({additive_model})" ); assert!( total > compacted_estimate, "some overhead must still be carried" ); } #[tokio::test] async fn compaction_reseed_excludes_post_response_deltas_from_overhead() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("y".repeat(4000))); h.handle.record_token_usage(11_000); h.handle .push_tool_result(ConversationItem::tool_result("c1", "z".repeat(100_000))); let compacted = vec![ConversationItem::user("s".repeat(2_000))]; h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; assert_eq!( total, 5_500, "overhead ratio must be measured against the last-response estimate \ (1k), not the live estimate inflated by the 25k post-response tool \ result, got {total}" ); } #[tokio::test] async fn compaction_overhead_unaffected_by_pruning_after_last_response() { let h = TestHarness::new(); // 12 turns of large tool results so default pruning (10-turn age) fires. let mut conv = Vec::new(); for i in 0..12 { conv.push(ConversationItem::user(format!("q{i}"))); conv.push(ConversationItem::tool_result( format!("call-{i}"), "x".repeat(200_000), )); } h.handle.replace_conversation(conv.clone()); for _ in 0..12 { h.handle.increment_prompt_index(); } let estimate_at_response = crate::estimate_conversation_tokens(&conv); let provider_total = estimate_at_response + estimate_at_response / 2; h.handle.record_token_usage(provider_total); // Triggers pruning: old tool results are hard-cleared in place. h.handle.push_user_message(ConversationItem::user("new q")); let pruned_estimate = crate::estimate_conversation_tokens(&h.handle.get_conversation().await); assert!( pruned_estimate + 20_000 < estimate_at_response, "test setup must actually prune ({pruned_estimate} vs {estimate_at_response})" ); let compacted = vec![ ConversationItem::system("sys"), ConversationItem::user("z".repeat(8_000)), ]; let compacted_estimate = crate::estimate_conversation_tokens(&compacted); h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; let expected = (compacted_estimate as f64 * (provider_total as f64 / estimate_at_response as f64)) .round() as u64; assert_eq!( total, expected, "overhead ratio must come from the snapshot at the last response, \ not be inflated by content pruned afterwards" ); } #[tokio::test] async fn restore_snapshot_preserves_frozen_estimate_for_overhead() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("q".repeat(4000))); let frozen = crate::estimate_conversation_tokens(&h.handle.get_conversation().await); let provider_total = frozen + 30_000; h.handle.record_token_usage(provider_total); // Rewind-style restore: trim the conversation, keep token fields. let mut snap = h.handle.snapshot().await.unwrap(); assert_eq!(snap.estimate_at_last_response, frozen); snap.conversation.clear(); h.handle.restore_snapshot(snap); let compacted = vec![ConversationItem::user("z".repeat(400))]; let compacted_estimate = crate::estimate_conversation_tokens(&compacted); h.handle.replace_conversation_for_compaction(compacted); let expected = (compacted_estimate as f64 * (provider_total as f64 / frozen as f64)).round() as u64; assert_eq!( h.handle.get_total_tokens().await, expected, "overhead ratio must use the frozen estimate carried through the snapshot" ); } #[tokio::test] async fn restore_snapshot_without_frozen_estimate_falls_back_to_recompute() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("q".repeat(4000))); let provider_total = 31_000; h.handle.record_token_usage(provider_total); // Pre-field snapshot (serde default): estimate_at_last_response == 0. let mut snap = h.handle.snapshot().await.unwrap(); snap.estimate_at_last_response = 0; h.handle.restore_snapshot(snap); let recomputed = crate::estimate_conversation_tokens(&h.handle.get_conversation().await); let compacted = vec![ConversationItem::user("z".repeat(400))]; let compacted_estimate = crate::estimate_conversation_tokens(&compacted); h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; let expected = (compacted_estimate as f64 * (provider_total as f64 / recomputed as f64)).round() as u64; assert_eq!( total, expected, "legacy snapshots must fall back to re-estimating the restored conversation" ); } #[tokio::test] async fn compaction_reseed_without_provider_count_matches_plain_estimate() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("hello")); let compacted = vec![ConversationItem::user("w".repeat(8000))]; h.handle.replace_conversation_for_compaction(compacted); let total = h.handle.get_total_tokens().await; assert_eq!( total, 2_000, "fresh-session reseed must be the raw estimate" ); } #[tokio::test] async fn non_compaction_replace_does_not_carry_overhead() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("x".repeat(4000))); h.handle.record_token_usage(51_000); h.handle .replace_conversation(vec![ConversationItem::user("q".repeat(4000))]); let total = h.handle.get_total_tokens().await; assert_eq!( total, 1_000, "non-compaction replace (e.g. rewind) keeps the plain estimate" ); } #[tokio::test] async fn flush_calls_persistence_flush() { let mut h = TestHarness::new(); h.handle.flush(); // Use a query as sync point to ensure flush was processed let _ = h.handle.get_prompt_index().await; let records = h.drain_persistence(); assert_eq!(records.len(), 1); assert!(matches!(&records[0], PersistenceRecord::Flush)); } #[tokio::test] async fn restore_snapshot_restores_all_fields() { let mut h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("msg")); h.handle.record_token_usage(500); h.handle.increment_prompt_index(); // Drain events from the mutations above let _ = h.handle.get_conversation().await; h.drain_events(); let snapshot = h.handle.snapshot().await.unwrap(); assert_eq!(snapshot.prompt_index, 1); assert_eq!(snapshot.total_tokens, 500); assert_eq!(snapshot.conversation.len(), 1); // Replace state h.handle.replace_conversation(vec![]); let _ = h.handle.get_conversation().await; // Restore h.handle.restore_snapshot(snapshot); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); let idx = h.handle.get_prompt_index().await; assert_eq!(idx, 1); let tokens = h.handle.get_total_tokens().await; assert_eq!(tokens, 500); } // Query tests #[tokio::test] async fn get_conversation_returns_current_state() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("msg1")); h.handle .push_assistant_response(ConversationItem::assistant("resp1")); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 2); } #[tokio::test] async fn get_total_tokens_returns_zero_initially() { let h = TestHarness::new(); let tokens = h.handle.get_total_tokens().await; assert_eq!(tokens, 0); } #[tokio::test] async fn replace_system_head_swaps_head_and_preserves_turns() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("old"), ConversationItem::user("hi"), ConversationItem::assistant("yo"), ]); let changed = h.handle.replace_system_head("new prompt").await; assert_eq!(changed, Some(true)); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 3, "must not wipe user/assistant turns"); assert!(matches!(&conv[0], ConversationItem::System(s) if s.content.as_ref() == "new prompt")); assert!(matches!(conv[1], ConversationItem::User(_))); assert!(matches!(conv[2], ConversationItem::Assistant(_))); } #[tokio::test] async fn replace_system_head_noop_when_head_matches_modulo_newline() { let mut h = TestHarness::with_conversation(vec![ ConversationItem::system("same\n"), ConversationItem::user("hi"), ]); // clear any seed writes let _ = h.drain_persistence(); let changed = h.handle.replace_system_head("same").await; assert_eq!( changed, Some(false), "trailing-newline-only diff is a no-op" ); let conv = h.handle.get_conversation().await; assert!(matches!(&conv[0], ConversationItem::System(s) if s.content.as_ref() == "same\n")); assert!( h.drain_persistence().is_empty(), "a no-op must not re-persist" ); } #[tokio::test] async fn replace_system_head_inserts_when_absent() { let h = TestHarness::with_conversation(vec![ConversationItem::user("hi")]); let changed = h.handle.replace_system_head("sys").await; assert_eq!(changed, Some(true)); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 2, "inserts System at head, keeps the user turn"); assert!(matches!(&conv[0], ConversationItem::System(s) if s.content.as_ref() == "sys")); assert!(matches!(conv[1], ConversationItem::User(_))); } /// Lost-update safety: an item pushed just before the head swap survives, because /// both operations serialize through the actor mailbox — the swap acts on the /// actor's current conversation, never a stale caller-side snapshot. This is the /// property that makes a mid-turn reconnect safe. #[tokio::test] async fn replace_system_head_retains_concurrently_pushed_item() { let h = TestHarness::with_conversation(vec![ConversationItem::system("old")]); h.handle .push_assistant_response(ConversationItem::assistant("in-flight turn output")); let changed = h.handle.replace_system_head("new").await; assert_eq!(changed, Some(true)); let conv = h.handle.get_conversation().await; assert_eq!( conv.len(), 2, "the item pushed before the swap must not be lost" ); assert!(matches!(&conv[0], ConversationItem::System(s) if s.content.as_ref() == "new")); assert!(matches!(conv[1], ConversationItem::Assistant(_))); } /// A head swap during an active turn capture must not drop the in-flight /// turn's captured tail (regression: `mem::take`ing the conversation before /// `replace_conversation` snapshotted it emptied the tail — a `debug_assert` /// panic in dev, a silently truncated capture in release). #[tokio::test] async fn replace_system_head_preserves_active_turn_capture() { let h = TestHarness::with_conversation(vec![ConversationItem::system("old")]); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("hello")); h.handle .push_assistant_response(ConversationItem::assistant("in-flight")); let changed = h.handle.replace_system_head("new prompt").await; assert_eq!(changed, Some(true)); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert_eq!( capture.messages.len(), 2, "mid-turn head swap must not drop the captured turn tail" ); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(_) )); } #[tokio::test] async fn empty_conversation_queries_return_defaults() { let h = TestHarness::new(); assert!(h.handle.get_conversation().await.is_empty()); assert_eq!(h.handle.get_prompt_index().await, 0); assert_eq!(h.handle.get_total_tokens().await, 0); assert!(h.handle.get_agent_edited_paths().await.is_empty()); } #[tokio::test] async fn check_auto_compact_returns_none_when_under_threshold() { let h = TestHarness::with_context_window(10000); h.handle.record_token_usage(100); // Sync point let _ = h.handle.get_total_tokens().await; let trigger = h.handle.check_auto_compact_needed(85).await; assert!(trigger.is_none()); } #[tokio::test] async fn check_auto_compact_triggers_at_threshold() { let h = TestHarness::with_context_window(10000); h.handle.record_token_usage(8600); let _ = h.handle.get_total_tokens().await; let trigger = h.handle.check_auto_compact_needed(85).await; assert!(trigger.is_some()); let t = trigger.unwrap(); assert_eq!(t.total_tokens, 8600); assert_eq!(t.context_window.get(), 10000); assert_eq!(t.utilization_percent, 86); } // Edge-case / integration tests #[tokio::test] async fn record_agent_edited_path_deduplicates() { let h = TestHarness::new(); h.handle.record_agent_edited_path("src/main.rs".to_string()); h.handle.record_agent_edited_path("src/main.rs".to_string()); h.handle.record_agent_edited_path("src/lib.rs".to_string()); let paths = h.handle.get_agent_edited_paths().await; assert_eq!(paths.len(), 2); } #[tokio::test] async fn cache_prompt_text_appends_in_order() { let h = TestHarness::new(); h.handle.cache_prompt_text("first".to_string()); h.handle.cache_prompt_text("second".to_string()); h.handle.cache_prompt_text("third".to_string()); let snap = h.handle.snapshot().await.unwrap(); assert_eq!(snap.prompt_texts, vec!["first", "second", "third"]); } #[tokio::test] async fn update_sampling_config_is_queryable() { let h = TestHarness::new(); let new_config = SamplingConfig { base_url: "https://new.example.com".to_string(), model: "kigi-3".to_string(), max_completion_tokens: Some(4096), temperature: Some(0.5), top_p: None, api_backend: Default::default(), chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(200_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, }; h.handle.update_sampling_config(new_config.clone()); let config = h.handle.get_sampling_config().await.unwrap(); assert_eq!(config.model, "kigi-3"); assert_eq!(config.context_window, NonZeroU64::new(200_000).unwrap()); } #[tokio::test] async fn notification_meta_reflects_timing() { let h = TestHarness::new(); h.handle.record_stream_start(1000); h.handle.record_turn_start(2000); let meta = h.handle.get_notification_meta().await.unwrap(); assert_eq!(meta.stream_start_ms, Some(1000)); assert_eq!(meta.turn_start_ms, Some(2000)); } #[tokio::test] async fn handle_clone_is_cheap_and_works() { let h = TestHarness::new(); let handle2 = h.handle.clone(); handle2.push_user_message(ConversationItem::user("from clone")); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 1); } #[tokio::test] async fn multiple_push_and_query_interleave() { let h = TestHarness::new(); for i in 0..10 { h.handle .push_user_message(ConversationItem::user(format!("msg-{i}"))); } let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 10); } #[tokio::test] async fn record_compaction_at_is_reflected_in_snapshot() { let h = TestHarness::new(); h.handle.record_compaction_at(3); let snap = h.handle.snapshot().await.unwrap(); assert_eq!(snap.last_compaction_prompt_index, Some(3)); } #[tokio::test] async fn truncate_removes_items_after_target_prompt_index() { let mut h = TestHarness::new(); // Build 3 turns: system + 3x (user + assistant) h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("q1")); h.handle.increment_prompt_index(); h.handle.cache_prompt_text("q1".to_string()); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle.push_user_message(ConversationItem::user("q2")); h.handle.increment_prompt_index(); h.handle.cache_prompt_text("q2".to_string()); h.handle .push_assistant_response(ConversationItem::assistant("a2")); h.handle.push_user_message(ConversationItem::user("q3")); h.handle.increment_prompt_index(); h.handle.cache_prompt_text("q3".to_string()); h.handle .push_assistant_response(ConversationItem::assistant("a3")); // Sync + drain let _ = h.handle.get_prompt_index().await; h.drain_events(); h.drain_persistence(); // Truncate to prompt_index 1 → keep sys, q1, a1 (stop before q2) h.handle.truncate_to_prompt_index(1).await; let conv = h.handle.get_conversation().await; // sys + q1 + a1 assert_eq!(conv.len(), 3); let idx = h.handle.get_prompt_index().await; assert_eq!(idx, 1); let snap = h.handle.snapshot().await.unwrap(); assert_eq!(snap.prompt_texts, vec!["q1"]); // Verify persistence was called let records = h.drain_persistence(); assert!( records .iter() .any(|r| matches!(r, PersistenceRecord::ReplaceHistory(_))) ); } #[tokio::test] async fn truncate_to_zero_keeps_only_system() { let mut h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("q1")); h.handle.increment_prompt_index(); h.handle .push_assistant_response(ConversationItem::assistant("a1")); let _ = h.handle.get_prompt_index().await; h.drain_events(); h.handle.truncate_to_prompt_index(0).await; let conv = h.handle.get_conversation().await; // just "sys" assert_eq!(conv.len(), 1); assert!(matches!(&conv[0], ConversationItem::System(_))); assert_eq!(h.handle.get_prompt_index().await, 0); } #[tokio::test] async fn truncate_is_noop_when_already_at_target() { let mut h = TestHarness::new(); h.handle.increment_prompt_index(); let _ = h.handle.get_prompt_index().await; h.drain_events(); h.drain_persistence(); h.handle.truncate_to_prompt_index(1).await; h.handle.truncate_to_prompt_index(5).await; let idx = h.handle.get_prompt_index().await; assert_eq!(idx, 1); let events = h.drain_events(); assert!(events.is_empty()); } // Snapshot/restore comprehensive tests #[tokio::test] async fn snapshot_restore_preserves_all_fields() { let h = TestHarness::new(); // Set up state with non-default values for EVERY field h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle.record_token_usage(999); h.handle.increment_prompt_index(); h.handle.record_agent_edited_path("src/foo.rs".to_string()); h.handle.record_agent_edited_path("src/bar.rs".to_string()); h.handle.cache_prompt_text("query 1".to_string()); h.handle.record_stream_start(12345); h.handle.record_turn_start(12340); h.handle.record_compaction_at(0); let snapshot = h.handle.snapshot().await.unwrap(); // Mutate everything to different values h.handle.replace_conversation(vec![]); h.handle.record_token_usage(0); h.handle.increment_prompt_index(); h.handle.record_stream_start(99999); h.handle.record_turn_start(99990); h.handle.record_compaction_at(99); // Verify mutated let _ = h.handle.get_prompt_index().await; assert!(h.handle.get_conversation().await.is_empty()); // Restore from snapshot h.handle.restore_snapshot(snapshot.clone()); // Verify ALL fields match assert_eq!( h.handle.get_conversation().await.len(), snapshot.conversation.len() ); assert_eq!(h.handle.get_total_tokens().await, snapshot.total_tokens); assert_eq!(h.handle.get_prompt_index().await, snapshot.prompt_index); assert_eq!( h.handle.get_agent_edited_paths().await, snapshot.agent_edited_paths ); let meta = h.handle.get_notification_meta().await.unwrap(); assert_eq!(meta.stream_start_ms, snapshot.stream_start_ms); assert_eq!(meta.turn_start_ms, snapshot.turn_start_ms); let restored_snap = h.handle.snapshot().await.unwrap(); assert_eq!(restored_snap.prompt_texts, snapshot.prompt_texts); assert_eq!( restored_snap.last_compaction_prompt_index, snapshot.last_compaction_prompt_index ); } #[tokio::test] async fn auto_compact_does_not_trigger_below_threshold() { let h = TestHarness::with_context_window(10000); h.handle.record_token_usage(8400); let _ = h.handle.get_total_tokens().await; let trigger = h.handle.check_auto_compact_needed(85).await; assert!(trigger.is_none()); } #[tokio::test] async fn with_initial_conversation_preserves_items() { let items = vec![ ConversationItem::system("sys prompt"), ConversationItem::user("initial msg"), ]; let h = TestHarness::with_conversation(items); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 2); } // BuildConversationRequest tests #[tokio::test] async fn build_request_includes_all_messages() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("hello")); // Sync point let _ = h.handle.get_conversation().await; let request = h .handle .build_request(vec![], None, false, None, "conv-1".into(), "req-1".into()) .await .unwrap(); assert_eq!(request.items.len(), 2); assert_eq!(request.x_kigi_conv_id, Some("conv-1".to_string())); assert_eq!(request.x_kigi_req_id, Some("req-1".to_string())); } #[tokio::test] async fn build_request_with_empty_conversation() { let h = TestHarness::new(); let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert!(request.items.is_empty()); } #[tokio::test] async fn build_request_preserves_system_message() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("You are a coding assistant."), ConversationItem::user("hi"), ]); let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!(request.items.len(), 2); if let ConversationItem::System(ref sys) = request.items[0] { assert_eq!(sys.content.as_ref(), "You are a coding assistant."); } else { panic!("expected System item"); } } #[tokio::test] async fn build_request_injects_memory_reminder() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("You are helpful."), ConversationItem::user("hi"), ]); let request = h .handle .build_request( vec![], Some("Remember: user prefers Rust".to_string()), false, None, "c".into(), "r".into(), ) .await .unwrap(); if let ConversationItem::System(ref sys) = request.items[0] { assert!(sys.content.contains("Remember: user prefers Rust")); assert!(sys.content.starts_with("You are helpful.")); } else { panic!("expected System item"); } } #[tokio::test] async fn build_request_injects_memory_when_no_system() { let h = TestHarness::with_conversation(vec![ConversationItem::user("hi")]); let request = h .handle .build_request( vec![], Some("Remember this".to_string()), false, None, "c".into(), "r".into(), ) .await .unwrap(); // new System + original User assert_eq!(request.items.len(), 2); assert!(matches!(&request.items[0], ConversationItem::System(_))); } #[tokio::test] async fn build_request_repairs_dangling_tool_calls() { use kigi_sampling_types::ToolCall; // Repair happens in ChatState::new() (called by with_conversation → spawn), // not in build_request. Both handler and clone-level repair are no-ops here. let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("do something"), ConversationItem::assistant_tool_calls(vec![ToolCall { id: "call_1".into(), name: "my_tool".to_string(), arguments: "{}".into(), }]), // No ToolResult for call_1 — repaired by ChatState::new() before any command. ]); let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); // Synthetic ToolResult present (inserted at construction, not at request time). assert_eq!(request.items.len(), 4); assert!(matches!(&request.items[3], ConversationItem::ToolResult(_))); } #[tokio::test] async fn build_request_with_tool_definitions() { use kigi_sampling_types::ToolSpec; let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hello"), ]); let tools = vec![ToolSpec { name: "read_file".to_string(), description: Some("Read a file".to_string()), parameters: serde_json::json!({"type": "object"}), }]; let request = h .handle .build_request(tools, None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!(request.tools.len(), 1); assert_eq!(request.tools[0].name, "read_file"); } #[tokio::test] async fn build_request_uses_sampling_config() { let config = SamplingConfig { base_url: "https://api.example.com".to_string(), model: "kigi-3".to_string(), max_completion_tokens: Some(8192), temperature: Some(0.7), top_p: Some(0.9), api_backend: Default::default(), chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(128_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, }; let h = TestHarness::with_config(vec![ConversationItem::user("hi")], config); let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!(request.model, Some("kigi-3".to_string())); assert_eq!(request.temperature, Some(0.7)); assert_eq!(request.max_output_tokens, Some(8192)); assert_eq!(request.top_p, Some(0.9)); } #[tokio::test] async fn build_request_does_not_mutate_actor_state() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hi"), ]); // Build request (which clones + potentially mutates the clone) let _ = h .handle .build_request( vec![], Some("injected memory".to_string()), false, None, "c".into(), "r".into(), ) .await .unwrap(); // Actor's own conversation should be `unchanged` let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 2); if let ConversationItem::System(ref sys) = conv[0] { // no memory injected into original assert_eq!(sys.content.as_ref(), "sys"); } } #[tokio::test] async fn build_request_can_persist_memory_into_actor_state() { let mut h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hi"), ]); let request = h .handle .build_request( vec![], Some("\nRemember this\n".to_string()), true, None, "c".into(), "r".into(), ) .await .unwrap(); if let ConversationItem::System(ref sys) = request.items[0] { assert!(sys.content.contains("Remember this")); } else { panic!("expected System item in request"); } let conv = h.handle.get_conversation().await; if let ConversationItem::System(ref sys) = conv[0] { assert!(sys.content.contains("Remember this")); } else { panic!("expected persisted System item"); } let records = h.drain_persistence(); assert!( records .iter() .any(|r| matches!(r, PersistenceRecord::ReplaceHistory(items) if matches!(items.first(), Some(ConversationItem::System(sys)) if sys.content.contains("Remember this")))) ); } #[tokio::test] async fn build_request_with_multiple_tool_calls_and_results() { use kigi_sampling_types::ToolCall; let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("do things"), ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "tool_a".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "tool_b".to_string(), arguments: "{}".into(), }, ]), ConversationItem::tool_result("call_1", "result a"), ConversationItem::tool_result("call_2", "result b"), ConversationItem::assistant("Done!"), ]); let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); // All items should pass through (no dangling calls, no pruning needed) assert_eq!(request.items.len(), 6); } // Parallel tool calls with mixed accept/reject /// Simulates the exact sequence that `kigi-shell`'s `execute_tool_calls` /// produces when the model emits 3 parallel tool calls and: /// - Tool #1 (read_file): user **accepts** → executed successfully /// - Tool #2 (edit_file): user **rejects** → handle_tool_not_executed /// - Tool #3 (run_terminal_cmd): **skipped** due to earlier rejection /// /// In the shell, `execute_tool_calls` iterates sequentially. When tool #2 is /// rejected, `final_result` is set to `PermissionReject`, causing tool #3 to /// be skipped with a synthetic cancellation message pushed as a ToolResult. /// /// The conversation should end up as: /// [0] System /// [1] User /// [2] Assistant (3 tool calls) /// [3] ToolResult for call_1 (success) /// [4] ToolResult for call_2 (rejection reason) /// [5] ToolResult for call_3 (cancellation due to earlier rejection) #[tokio::test] async fn parallel_tool_calls_accept_first_reject_second_skip_third() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); // Turn setup // System prompt h.handle.push_user_message(ConversationItem::system( "You are a helpful coding assistant.", )); // User asks the model to do something complex h.handle.push_user_message(ConversationItem::user( "Read main.rs, fix the typo, then run the tests", )); h.handle.increment_prompt_index(); // Model response: 3 parallel tool calls // The model's single assistant message contains all 3 tool calls. // In the real code, this is built from the streaming response and pushed // via `push_assistant_response`. let assistant_with_tools = ConversationItem::Assistant(kigi_sampling_types::AssistantItem { content: "I'll read the file, fix it, and run tests.".into(), tool_calls: vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: r#"{"target_file":"src/main.rs"}"#.into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: r#"{"target_file":"src/main.rs","new_string":"fixed"}"#.into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: r#"{"command":"cargo test"}"#.into(), }, ], model_id: Some("kigi-3".to_string()), model_fingerprint: None, reasoning_effort: None, }); h.handle.push_assistant_response(assistant_with_tools); // Tool execution results (simulating execute_tool_calls) // Tool #1: read_file — user accepted, tool executed successfully h.handle.push_tool_result(ConversationItem::tool_result( "call_1", "fn main() {\n println!(\"hello wrold\");\n}", )); // Tool #2: edit_file — user rejected via permission prompt // In the shell, `handle_tool_not_executed` pushes a ToolResult with the // rejection reason and returns ToolLoop::PermissionReject. h.handle.push_tool_result(ConversationItem::tool_result( "call_2", "User rejected: permission denied for tool `edit_file`", )); // Tool #3: run_terminal_cmd — skipped because tool #2 was rejected. // In `execute_tool_calls`, when `final_result` is set, remaining tools // get a synthetic cancellation message. h.handle.push_tool_result(ConversationItem::tool_result( "call_3", "Tool execution cancelled due to earlier permission rejection for tool `run_terminal_cmd`", )); // Verify the conversation state let conv = h.handle.get_conversation().await; // Expected: System + User + Assistant(3 calls) + 3 ToolResults = 6 items assert_eq!( conv.len(), 6, "expected 6 conversation items, got {}", conv.len() ); // [0] System assert!( matches!(&conv[0], ConversationItem::System(s) if s.content.as_ref() == "You are a helpful coding assistant."), "item[0] should be the system prompt" ); // [1] User assert!( matches!(&conv[1], ConversationItem::User(_)), "item[1] should be the user message" ); // [2] Assistant with 3 tool calls match &conv[2] { ConversationItem::Assistant(a) => { assert_eq!(a.tool_calls.len(), 3, "assistant should have 3 tool calls"); assert_eq!(a.tool_calls[0].id.as_ref(), "call_1"); assert_eq!(a.tool_calls[0].name, "read_file"); assert_eq!(a.tool_calls[1].id.as_ref(), "call_2"); assert_eq!(a.tool_calls[1].name, "edit_file"); assert_eq!(a.tool_calls[2].id.as_ref(), "call_3"); assert_eq!(a.tool_calls[2].name, "run_terminal_cmd"); assert_eq!( a.content.as_ref(), "I'll read the file, fix it, and run tests." ); } other => panic!("item[2] should be Assistant, got {:?}", other), } // [3] ToolResult for call_1 — success match &conv[3] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, "call_1"); assert!( tr.content.contains("hello wrold"), "tool_result for call_1 should contain the file content" ); } other => panic!("item[3] should be ToolResult, got {:?}", other), } // [4] ToolResult for call_2 — rejected match &conv[4] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, "call_2"); assert!( tr.content.contains("rejected") || tr.content.contains("denied"), "tool_result for call_2 should indicate rejection, got: {}", tr.content ); } other => panic!("item[4] should be ToolResult, got {:?}", other), } // [5] ToolResult for call_3 — cancelled due to earlier rejection match &conv[5] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, "call_3"); assert!( tr.content.contains("cancelled") && tr.content.contains("earlier permission rejection"), "tool_result for call_3 should indicate cancellation due to earlier rejection, got: {}", tr.content ); } other => panic!("item[5] should be ToolResult, got {:?}", other), } } /// After parallel tool calls with rejection, verify that `build_request` /// sees no dangling tool calls (every call has a matching ToolResult). /// This is important because dangling calls trigger synthetic repair which /// would corrupt the rejection messages. #[tokio::test] async fn parallel_tool_calls_with_rejection_has_no_dangling_calls() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle .push_user_message(ConversationItem::user("do things")); // Assistant with 3 parallel tool calls h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: "{}".into(), }, ])); // All 3 get ToolResults (accept, reject, skip) h.handle .push_tool_result(ConversationItem::tool_result("call_1", "file contents")); h.handle .push_tool_result(ConversationItem::tool_result("call_2", "rejected by user")); h.handle.push_tool_result(ConversationItem::tool_result( "call_3", "Tool execution cancelled due to earlier permission rejection for tool `run_terminal_cmd`", )); // Build request — should NOT add any synthetic ToolResults let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); // 2 (sys+user) + 1 (assistant) + 3 (tool results) = 6 assert_eq!( request.items.len(), 6, "build_request should not insert synthetic tool results when all calls have results" ); // Verify the tool results are preserved with their original content let tool_results: Vec<_> = request .items .iter() .filter_map(|item| { if let ConversationItem::ToolResult(tr) = item { Some(tr) } else { None } }) .collect(); assert_eq!(tool_results.len(), 3); assert_eq!(tool_results[0].tool_call_id, "call_1"); assert_eq!(tool_results[0].content.as_ref(), "file contents"); assert_eq!(tool_results[1].tool_call_id, "call_2"); assert_eq!(tool_results[1].content.as_ref(), "rejected by user"); assert_eq!(tool_results[2].tool_call_id, "call_3"); assert!(tool_results[2].content.contains("cancelled")); } /// Verify that persistence records all 5 pushes (assistant + 3 tool results) /// correctly for the parallel tool call scenario. #[tokio::test] async fn parallel_tool_calls_with_rejection_persists_all_items() { use kigi_sampling_types::ToolCall; let mut h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle .push_user_message(ConversationItem::user("do things")); h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: "{}".into(), }, ])); h.handle .push_tool_result(ConversationItem::tool_result("call_1", "success")); h.handle .push_tool_result(ConversationItem::tool_result("call_2", "rejected")); h.handle .push_tool_result(ConversationItem::tool_result("call_3", "cancelled")); // Sync point let _ = h.handle.get_conversation().await; // All 6 items should have been persisted as Message records let records = h.drain_persistence(); let message_count = records .iter() .filter(|r| matches!(r, PersistenceRecord::Message(_))) .count(); assert_eq!( message_count, 6, "expected 6 persisted messages (sys + user + assistant + 3 tool results), got {}", message_count ); } // Race condition: cancellation mid-tool-execution → dangling calls on reload /// Simulates the race condition where: /// 1. Model emits 3 parallel tool calls (single assistant message) /// 2. Tool #1 executes and its result is persisted /// 3. User cancels (Ctrl+C) or app crashes BEFORE tool #2/#3 results are pushed /// 4. On session reload, chat_history.jsonl has the assistant (3 calls) + only 1 result /// /// `ChatState::new` now repairs dangling tool calls eagerly at initialization, /// so the actor's in-memory conversation is clean from the start — not just the /// clone produced by `build_request`. #[tokio::test] async fn dangling_tool_calls_after_crash_are_repaired_on_load() { use kigi_sampling_types::ToolCall; // Simulate what chat_history.jsonl looks like after a crash: // The assistant message (with 3 tool calls) was persisted, and only // tool #1's result was persisted before the process died. let crashed_conversation = vec![ ConversationItem::system("You are a helpful assistant."), ConversationItem::user("Read, edit, and test"), ConversationItem::Assistant(kigi_sampling_types::AssistantItem { content: std::sync::Arc::::from(""), tool_calls: vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: r#"{"target_file":"src/main.rs"}"#.into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: r#"{"target_file":"src/main.rs","new_string":"fixed"}"#.into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: r#"{"command":"cargo test"}"#.into(), }, ], model_id: Some("kigi-3".to_string()), model_fingerprint: None, reasoning_effort: None, }), // Only call_1 got persisted before the crash ConversationItem::tool_result("call_1", "fn main() { ... }"), // call_2 and call_3 are MISSING — this is the dangling state ]; // "Reload" the session by creating an actor with the crashed conversation. // ChatState::new repairs dangling tool calls eagerly. let h = TestHarness::with_conversation(crashed_conversation); // The actor's conversation should already be repaired (6 items, not 4) let conv = h.handle.get_conversation().await; assert_eq!( conv.len(), 6, "actor should repair dangling calls on load: expected 6 items (sys + user + assistant + 3 results), got {}", conv.len() ); // Verify the tool results let tool_results: Vec<_> = conv .iter() .filter_map(|item| { if let ConversationItem::ToolResult(tr) = item { Some(tr) } else { None } }) .collect(); assert_eq!( tool_results.len(), 3, "should have 3 tool results (1 real + 2 synthetic)" ); // call_1: real result (persisted before crash) assert_eq!(tool_results[0].tool_call_id, "call_1"); assert!( tool_results[0].content.contains("fn main"), "call_1 should have the original result" ); // call_2: synthetic repair assert_eq!(tool_results[1].tool_call_id, "call_2"); assert!( tool_results[1].content.contains("cancelled") || tool_results[1].content.contains("not executed"), "call_2 should have a synthetic cancellation result, got: {}", tool_results[1].content ); // call_3: synthetic repair assert_eq!(tool_results[2].tool_call_id, "call_3"); assert!( tool_results[2].content.contains("cancelled") || tool_results[2].content.contains("not executed"), "call_3 should have a synthetic cancellation result, got: {}", tool_results[2].content ); // build_request should also see 6 items (no double-repair) let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!( request.items.len(), 6, "build_request should not double-repair already-fixed conversation" ); } /// Verify that both the actor state AND the build_request output are /// consistent after repair — the synthetic results appear in both. #[tokio::test] async fn dangling_tool_calls_repair_is_consistent_between_state_and_request() { use kigi_sampling_types::ToolCall; let crashed_conversation = vec![ ConversationItem::system("sys"), ConversationItem::user("do stuff"), ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ]), // Only call_1 has a result — call_2 is dangling ConversationItem::tool_result("call_1", "ok"), ]; let h = TestHarness::with_conversation(crashed_conversation); // Actor state should have 5 items (repaired on load) let conv = h.handle.get_conversation().await; assert_eq!( conv.len(), 5, "actor state should be repaired on load: sys + user + assistant + 2 results" ); // build_request should match (no extra synthetic results) let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!( request.items.len(), 5, "build_request should match actor state length" ); } /// Worst case: crash happens right after the assistant message is persisted /// but BEFORE any tool results. All 3 tool calls are dangling. /// ChatState::new should repair all 3 eagerly. #[tokio::test] async fn all_tool_calls_dangling_after_crash() { use kigi_sampling_types::ToolCall; let crashed_conversation = vec![ ConversationItem::system("sys"), ConversationItem::user("do everything"), ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: "{}".into(), }, ]), // NO tool results at all — complete crash right after assistant was persisted ]; let h = TestHarness::with_conversation(crashed_conversation); // Actor state should be repaired: sys + user + assistant + 3 synthetic results = 6 let conv = h.handle.get_conversation().await; assert_eq!( conv.len(), 6, "all 3 dangling calls should be repaired on load" ); let tool_results: Vec<_> = conv .iter() .filter_map(|item| { if let ConversationItem::ToolResult(tr) = item { Some(tr) } else { None } }) .collect(); assert_eq!(tool_results.len(), 3); // All should be synthetic cancellation messages for (i, tr) in tool_results.iter().enumerate() { assert_eq!(tr.tool_call_id, format!("call_{}", i + 1)); assert!( tr.content.contains("cancelled") || tr.content.contains("not executed"), "call_{} should have synthetic cancellation, got: {}", i + 1, tr.content ); } // build_request should also see 6 items — no double-repair let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!(request.items.len(), 6); } // Live-session cancellation: user cancels mid-tool-execution (no restart) /// Simulates an in-session abort where: /// 1. Model emits 3 parallel tool calls → assistant pushed to conversation /// 2. User immediately cancels (Ctrl+C) → tokio task aborted /// 3. Zero tool results pushed (abort happened before execute_tool_calls) /// 4. TUI stays alive, user types a new prompt /// /// This is different from the reload scenario: `ChatState::new` doesn't run /// again because the actor is still alive. The fix is that `push_user_message` /// calls `repair_dangling_tool_calls` before appending the new user /// message, so the conversation is cleaned up in-place. #[tokio::test] async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); // Turn 1: normal conversation h.handle .push_user_message(ConversationItem::system("You are a helpful assistant.")); h.handle.push_user_message(ConversationItem::user("Hello")); h.handle .push_assistant_response(ConversationItem::assistant("Hi! How can I help?")); // Turn 2: model wants 3 tool calls, user cancels immediately h.handle .push_user_message(ConversationItem::user("Read, edit, and test everything")); // Model streams its response → assistant with 3 tool calls is pushed h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: r#"{"target_file":"src/main.rs"}"#.into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: r#"{"command":"cargo test"}"#.into(), }, ])); // USER CANCELS HERE (Ctrl+C) // The tokio task is aborted. execute_tool_calls never ran. // Zero ToolResult items pushed. The conversation has dangling calls. // get_conversation() and snapshot() are pure reads — they do NOT repair. // The dangling calls are visible in the raw state until the next write boundary. let conv_before = h.handle.get_conversation().await; // sys + user("Hello") + assistant("Hi!") + user("Read...") + assistant(3 calls) = 5 assert_eq!( conv_before.len(), 5, "get_conversation() should be a pure read (no repair), got {} items", conv_before.len() ); let snap = h.handle.snapshot().await.unwrap(); assert_eq!( snap.conversation.len(), 5, "snapshot() should be a pure read (no repair)" ); // ── Turn 3: push_user_message() is the write boundary; repairs here. h.handle .push_user_message(ConversationItem::user("Actually, just read the file")); let conv = h.handle.get_conversation().await; // sys + user + assistant + user + assistant(3 calls) + 3 repairs + user = 9 assert_eq!(conv.len(), 9); // Verify the synthetic repairs are in the right place for (idx, expected_call_id) in [(5, "call_1"), (6, "call_2"), (7, "call_3")] { match &conv[idx] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, expected_call_id); assert!( tr.content.contains("cancelled") || tr.content.contains("not executed"), "item[{}] should be synthetic cancellation for {}, got: {}", idx, expected_call_id, tr.content ); } other => panic!( "item[{}] should be synthetic ToolResult for {}, got {:?}", idx, expected_call_id, other ), } } // New user message is at the end assert!( matches!(&conv[8], ConversationItem::User(_)), "item[8] should be the new user message" ); // build_request should work cleanly — no dangling calls, no double-repair let request = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); assert_eq!(request.items.len(), 9); } /// Partial cancellation: tool #1 result was pushed, then user cancelled. /// Tools #2 and #3 are dangling. Next user message should repair only those. #[tokio::test] async fn live_cancel_after_partial_tool_results_repairs_remaining() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle .push_user_message(ConversationItem::user("do everything")); // Model returns 3 parallel tool calls h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ ToolCall { id: "call_1".into(), name: "read_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_2".into(), name: "edit_file".to_string(), arguments: "{}".into(), }, ToolCall { id: "call_3".into(), name: "run_terminal_cmd".to_string(), arguments: "{}".into(), }, ])); // Tool #1 executed and result was pushed before abort h.handle.push_tool_result(ConversationItem::tool_result( "call_1", "file contents here", )); // USER CANCELS HERE — tool #2 and #3 never executed // User types a new prompt h.handle.push_user_message(ConversationItem::user( "never mind, just help me with something else", )); let conv = h.handle.get_conversation().await; // sys + user + assistant(3 calls) + result(call_1) + repair(call_2) + repair(call_3) + user(new) = 7 assert_eq!( conv.len(), 7, "expected 7 items after partial repair, got {}", conv.len() ); // call_1 should still have the real result match &conv[3] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, "call_1"); assert_eq!(tr.content.as_ref(), "file contents here"); } other => panic!( "item[3] should be real ToolResult for call_1, got {:?}", other ), } // call_2 and call_3 should be synthetic repairs for (idx, expected_call_id) in [(4, "call_2"), (5, "call_3")] { match &conv[idx] { ConversationItem::ToolResult(tr) => { assert_eq!(tr.tool_call_id, expected_call_id); assert!( tr.content.contains("cancelled") || tr.content.contains("not executed"), "item[{}] should be synthetic for {}, got: {}", idx, expected_call_id, tr.content ); } other => panic!("item[{}] should be ToolResult, got {:?}", idx, other), } } // New user message at the end assert!(matches!(&conv[6], ConversationItem::User(_))); } // Turn message capture tests #[tokio::test] async fn turn_capture_collects_all_message_types() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("hello")); h.handle .push_assistant_response(ConversationItem::assistant("hi")); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "result")); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert_eq!(capture.messages.len(), 3); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(_) )); assert!(matches!( &capture.messages[2], ConversationItem::ToolResult(_) )); assert!(!capture.compaction_occurred); } #[tokio::test] async fn harness_trace_items_ride_own_turn_not_the_live_capture() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); let live_before = h.handle.get_conversation().await.len(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("u1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); // Verifier-style: synthetic `task` pair recorded mid-turn must NOT splice // into the main turn capture — it rides its own harness trace turn. h.handle.append_harness_trace_items(vec![ ConversationItem::assistant_tool_calls(vec![ToolCall { id: "verifier-1".into(), name: "spawn_subagent".into(), arguments: "{}".into(), }]), ConversationItem::tool_result("verifier-1", "Refuted"), ]); h.handle.flush_harness_trace_turn(); h.handle.push_user_message(ConversationItem::user("u2")); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); // The main turn capture holds only the live items — no harness pair. assert_eq!(capture.messages.len(), 3); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(_) )); assert!(matches!(&capture.messages[2], ConversationItem::User(_))); // The harness pair is drained as its own standalone trace turn. let harness = h.handle.take_harness_trace_turns().await; assert_eq!(harness.len(), 1, "one sealed verifier trace turn"); assert_eq!(harness[0].len(), 2); assert!(matches!( &harness[0][0], ConversationItem::Assistant(a) if !a.tool_calls.is_empty() )); assert!(matches!(&harness[0][1], ConversationItem::ToolResult(_))); // Harness items never enter the live conversation fed to the model. let live_after = h.handle.get_conversation().await.len(); assert_eq!( live_after - live_before, 3, "live conversation gained only u1, a1, u2 — not the harness pair", ); } #[tokio::test] async fn harness_trace_recorded_before_capture_seals_into_own_turn() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); // Planner-style: synthetic pair recorded BEFORE the turn's capture starts // (the goal planner runs during setup_goal, ahead of begin_turn_capture). h.handle.append_harness_trace_items(vec![ ConversationItem::assistant_tool_calls(vec![ToolCall { id: "planner-1".into(), name: "spawn_subagent".into(), arguments: "{}".into(), }]), ConversationItem::tool_result("planner-1", "Done"), ]); h.handle.flush_harness_trace_turn(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("u1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); // The main capture holds only the live turn items — the planner pair does // not lead it. assert_eq!(capture.messages.len(), 2); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(_) )); // The planner pair is its own harness trace turn. let harness = h.handle.take_harness_trace_turns().await; assert_eq!(harness.len(), 1); assert_eq!(harness[0].len(), 2); assert!(matches!( &harness[0][0], ConversationItem::Assistant(a) if !a.tool_calls.is_empty() )); assert!(matches!(&harness[0][1], ConversationItem::ToolResult(_))); } #[tokio::test] async fn harness_trace_turns_separate_per_flush_and_drain_clears() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); let pair = |id: &str| { vec![ ConversationItem::assistant_tool_calls(vec![ToolCall { id: id.into(), name: "spawn_subagent".into(), arguments: "{}".into(), }]), ConversationItem::tool_result(id, "Refuted"), ] }; // Two verifier panels (rounds) each seal into their own turn; an // un-flushed third accumulator is sealed defensively on take. h.handle.append_harness_trace_items(pair("round-1")); h.handle.flush_harness_trace_turn(); h.handle.append_harness_trace_items(pair("round-2")); h.handle.flush_harness_trace_turn(); h.handle .append_harness_trace_items(pair("round-3-unflushed")); let harness = h.handle.take_harness_trace_turns().await; assert_eq!(harness.len(), 3, "two flushed + one defensively sealed"); // Drain clears the buffer — a second take returns nothing. let second = h.handle.take_harness_trace_turns().await; assert!(second.is_empty()); // The buffers are reusable across user turns: a fresh append + flush after // the drain produces a new turn, not a leftover from the cleared batch. h.handle.append_harness_trace_items(pair("turn-2")); h.handle.flush_harness_trace_turn(); let reused = h.handle.take_harness_trace_turns().await; assert_eq!(reused.len(), 1, "append-after-drain seals a fresh turn"); assert_eq!(reused[0].len(), 2); } #[tokio::test] async fn flush_without_items_is_noop() { let h = TestHarness::new(); // Fail-open verifier path (no skeptics spawned) flushes nothing. h.handle.flush_harness_trace_turn(); let harness = h.handle.take_harness_trace_turns().await; assert!(harness.is_empty()); } #[tokio::test] async fn turn_capture_survives_compaction_and_flags_it() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle .replace_conversation_for_compaction(vec![ConversationItem::system("compacted")]); h.handle.push_user_message(ConversationItem::user("q2")); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert_eq!(capture.messages.len(), 3); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(_) )); assert!(matches!(&capture.messages[2], ConversationItem::User(_))); assert!(capture.compaction_occurred); } #[tokio::test] async fn replace_conversation_without_compaction_does_not_set_flag() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .replace_conversation(vec![ConversationItem::system("new state")]); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert!(!capture.compaction_occurred); } #[tokio::test] async fn take_without_begin_returns_none() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("hello")); let result = h.handle.take_turn_messages().await; assert!(result.is_none()); } #[tokio::test] async fn take_twice_returns_none_second_time() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("hello")); let first = h.handle.take_turn_messages().await; assert!(first.is_some()); let second = h.handle.take_turn_messages().await; assert!(second.is_none()); } #[tokio::test] async fn begin_capture_clears_previous_buffer() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("old")); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("new")); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert_eq!(capture.messages.len(), 1); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); } #[tokio::test] async fn truncate_clears_turn_capture() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("q1")); h.handle.increment_prompt_index(); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("q2")); h.handle.increment_prompt_index(); h.handle.truncate_to_prompt_index(1).await; let result = h.handle.take_turn_messages().await; assert!(result.is_none()); } #[tokio::test] async fn restore_snapshot_does_not_touch_turn_capture() { let h = TestHarness::new(); let snapshot = h.handle.snapshot().await.unwrap(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("hello")); h.handle .push_assistant_response(ConversationItem::assistant("hi")); h.handle.restore_snapshot(snapshot); let capture = h .handle .take_turn_messages() .await .expect("capture should survive restore"); assert_eq!(capture.messages.len(), 2); assert!(!capture.compaction_occurred); } #[tokio::test] async fn turn_capture_survives_integrity_repair_prefix_shrink() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); // Build a prefix (before the capture starts) holding three removable // duplicate ToolResults — one per tool call. `dedup_duplicate_tool_results` // keeps the last result per id and drops the earlier one, shrinking the // prefix by three items when integrity repair later runs. let call = |id: &'static str| ToolCall { id: id.into(), name: "t".into(), arguments: "{}".into(), }; h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ call("call-1"), call("call-2"), call("call-3"), ])); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "dup-1")); h.handle .push_tool_result(ConversationItem::tool_result("call-1", "real-1")); h.handle .push_tool_result(ConversationItem::tool_result("call-2", "dup-2")); h.handle .push_tool_result(ConversationItem::tool_result("call-2", "real-2")); h.handle .push_tool_result(ConversationItem::tool_result("call-3", "dup-3")); h.handle .push_tool_result(ConversationItem::tool_result("call-3", "real-3")); // Capture starts after the 7-item prefix: turn_start_offset == 7. h.handle.begin_turn_capture(); h.handle .push_assistant_response(ConversationItem::assistant("turn-1")); // Integrity repair removes the three prefix duplicates, shrinking the // conversation to len 5 while the un-rebased offset stays at 7 (offset 7 > // len 5). Without the fix the later take_turn_messages slice is out of range, // panics the actor, and the query comes back as None. h.handle.repair_dangling_after_harness_halt("test-halt"); // Second turn item lands after the rebase — it must still be captured. h.handle .push_assistant_response(ConversationItem::assistant("turn-2")); let capture = h .handle .take_turn_messages() .await .expect("capture survived the in-place integrity repair (no actor panic)"); // turn-1 via the pre-repair snapshot, turn-2 via the rebased offset; none of // the deduped prefix items leak in. assert_eq!(capture.messages.len(), 2); assert!(matches!( &capture.messages[0], ConversationItem::Assistant(a) if a.content.as_ref() == "turn-1" && a.tool_calls.is_empty() )); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(a) if a.content.as_ref() == "turn-2" && a.tool_calls.is_empty() )); } #[tokio::test] async fn integrity_repair_does_not_flag_compaction() { let h = TestHarness::new(); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); // An in-place integrity repair goes through `snapshot_turn_slice` like // compaction does, but it is NOT compaction — the flag must stay unset. h.handle.repair_dangling_after_harness_halt("test-halt"); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); assert!(!capture.compaction_occurred); assert_eq!(capture.messages.len(), 2); } #[tokio::test] async fn turn_capture_survives_persisted_memory_reminder_prepend() { // No leading System item, so the persisted memory inject prepends one via // `items.insert(0, ..)`, shifting every index by one under an active capture. let h = TestHarness::with_conversation(vec![ConversationItem::user("hi")]); h.handle.begin_turn_capture(); h.handle.push_user_message(ConversationItem::user("turn-q")); h.handle .push_assistant_response(ConversationItem::assistant("turn-a")); // Persist path injects into the LIVE conversation; without the snapshot + // rebase the prepend shifts every index but not the offset, so the off-by-one // tail over-reads past the turn boundary (the len check below catches it). let request = h .handle .build_request( vec![], Some("Remember this".to_string()), true, None, "c".into(), "r".into(), ) .await .unwrap(); assert!(matches!(&request.items[0], ConversationItem::System(_))); let capture = h .handle .take_turn_messages() .await .expect("capture was active"); // Exactly the two turn items, in order. assert_eq!(capture.messages.len(), 2); assert!(matches!(&capture.messages[0], ConversationItem::User(_))); assert!(matches!( &capture.messages[1], ConversationItem::Assistant(a) if a.content.as_ref() == "turn-a" )); } // Narrow targeted query tests #[tokio::test] async fn get_conversation_len_empty() { let h = TestHarness::new(); assert_eq!(h.handle.get_conversation_len().await, 0); } #[tokio::test] async fn get_conversation_len_matches_full_conversation() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("a")); h.handle .push_assistant_response(ConversationItem::assistant("b")); h.handle .push_tool_result(ConversationItem::tool_result("c1", "r")); // Use len query as sync point and verify it matches the full vec let len = h.handle.get_conversation_len().await; let full = h.handle.get_conversation().await; assert_eq!(len, full.len()); assert_eq!(len, 3); } #[tokio::test] async fn has_dangling_tool_calls_reflects_unanswered_calls() { use kigi_sampling_types::ToolCall; let h = TestHarness::new(); assert!(!h.handle.has_dangling_tool_calls().await); // `push_assistant_response` does not run the repair, so the unanswered call // stays dangling (mirrors a turn parked mid-tool / on a permission prompt). h.handle .push_assistant_response(ConversationItem::assistant_tool_calls(vec![ToolCall { id: "call_1".into(), name: "my_tool".to_string(), arguments: "{}".into(), }])); assert!(h.handle.has_dangling_tool_calls().await); // Answering the call clears the dangling state. h.handle .push_tool_result(ConversationItem::tool_result("call_1", "ok")); assert!(!h.handle.has_dangling_tool_calls().await); } #[tokio::test] async fn get_last_assistant_text_empty_conversation() { let h = TestHarness::new(); assert!(h.handle.get_last_assistant_text().await.is_none()); } #[tokio::test] async fn get_last_assistant_text_returns_last_nonempty() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("first answer")); h.handle.push_user_message(ConversationItem::user("q2")); h.handle .push_assistant_response(ConversationItem::assistant("second answer")); let text = h.handle.get_last_assistant_text().await; assert_eq!(text.as_deref(), Some("second answer")); } #[tokio::test] async fn get_last_assistant_text_skips_whitespace_only() { let h = TestHarness::new(); h.handle .push_assistant_response(ConversationItem::assistant("real answer")); // Push a whitespace-only assistant message after the real one h.handle .push_assistant_response(ConversationItem::assistant(" \n ")); let text = h.handle.get_last_assistant_text().await; // Must skip the whitespace-only entry and return the previous one assert_eq!(text.as_deref(), Some("real answer")); } #[tokio::test] async fn get_last_assistant_text_no_assistant_messages() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("hi")); assert!(h.handle.get_last_assistant_text().await.is_none()); } #[tokio::test] async fn get_first_user_text_empty_conversation() { let h = TestHarness::new(); assert!(h.handle.get_first_user_text().await.is_none()); } #[tokio::test] async fn get_first_user_text_returns_first_user_text() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle .push_user_message(ConversationItem::user("hello world")); h.handle.push_user_message(ConversationItem::user("second")); let text = h.handle.get_first_user_text().await; assert_eq!(text.as_deref(), Some("hello world")); } #[tokio::test] async fn get_first_user_text_no_user_messages() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::system("sys only")); assert!(h.handle.get_first_user_text().await.is_none()); } #[tokio::test] async fn get_conversation_item_at_out_of_bounds() { let h = TestHarness::new(); assert!(h.handle.get_conversation_item_at(0).await.is_none()); assert!(h.handle.get_conversation_item_at(99).await.is_none()); } #[tokio::test] async fn get_conversation_item_at_returns_correct_item() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("hello")); h.handle .push_assistant_response(ConversationItem::assistant("hi")); // Sync point let _ = h.handle.get_conversation_len().await; let item0 = h.handle.get_conversation_item_at(0).await.unwrap(); assert!(matches!(item0, ConversationItem::System(_))); let item1 = h.handle.get_conversation_item_at(1).await.unwrap(); assert!(matches!(item1, ConversationItem::User(_))); let item2 = h.handle.get_conversation_item_at(2).await.unwrap(); assert!(matches!(item2, ConversationItem::Assistant(_))); assert!(h.handle.get_conversation_item_at(3).await.is_none()); } #[tokio::test] async fn get_conversation_item_at_does_not_mutate_state() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("q")); // Fetching item[1] should not change what get_conversation returns let _ = h.handle.get_conversation_item_at(1).await; let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 2); } // Multimodal regression tests for get_first_user_text() /// Confirms that `get_first_user_text()` returns `None` when the first content /// part of the first user message is an image (not text). This preserves the /// original call-site semantics used by the memory-search path. #[tokio::test] async fn get_first_user_text_image_first_returns_none() { use kigi_sampling_types::{ContentPart, UserItem}; let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::User(UserItem { content: vec![ContentPart::Image { url: "data:image/png;base64,abc".into(), }], synthetic_reason: None, ..Default::default() })); // Must return None — first content part is not Text. assert!(h.handle.get_first_user_text().await.is_none()); } /// Confirms that when the first user message has an image first and text second, /// `get_first_user_text()` still returns `None` (first-part-is-text semantics). #[tokio::test] async fn get_first_user_text_image_then_text_returns_none() { use kigi_sampling_types::{ContentPart, UserItem}; let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::User(UserItem { content: vec![ ContentPart::Image { url: "data:image/png;base64,abc".into(), }, ContentPart::Text { text: "describe this image".into(), }, ], synthetic_reason: None, ..Default::default() })); // Still None — first part is an image, so we do not fall through to the text part. assert!(h.handle.get_first_user_text().await.is_none()); } /// Confirms the happy path for text-first multimodal messages. #[tokio::test] async fn get_first_user_text_text_then_image_returns_text() { use kigi_sampling_types::{ContentPart, UserItem}; let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::User(UserItem { content: vec![ ContentPart::Text { text: "look at this".into(), }, ContentPart::Image { url: "data:image/png;base64,abc".into(), }, ], synthetic_reason: None, ..Default::default() })); let text = h.handle.get_first_user_text().await; assert_eq!(text.as_deref(), Some("look at this")); } // Tests for GetLastUserQueryText, GetConversationCounts, GetSystemMessage #[tokio::test] async fn get_last_user_query_text_empty_conversation() { let h = TestHarness::new(); assert!(h.handle.get_last_user_query_text().await.is_none()); } #[tokio::test] async fn get_last_user_query_text_returns_last() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::user("first question")); h.handle .push_assistant_response(ConversationItem::assistant("answer")); h.handle .push_user_message(ConversationItem::user("second question")); let text = h.handle.get_last_user_query_text().await; assert_eq!(text.as_deref(), Some("second question")); } #[tokio::test] async fn get_conversation_counts_empty() { let h = TestHarness::new(); let counts = h.handle.get_conversation_counts().await; assert_eq!(counts.total, 0); assert_eq!(counts.user, 0); assert_eq!(counts.assistant, 0); assert_eq!(counts.tool_result, 0); } #[tokio::test] async fn get_conversation_counts_mixed() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle .push_tool_result(ConversationItem::tool_result("c1", "r1")); h.handle.push_user_message(ConversationItem::user("q2")); h.handle .push_assistant_response(ConversationItem::assistant("a2")); let counts = h.handle.get_conversation_counts().await; assert_eq!(counts.total, 6); assert_eq!(counts.user, 2); assert_eq!(counts.assistant, 2); assert_eq!(counts.tool_result, 1); } #[tokio::test] async fn get_system_message_none_when_absent() { let h = TestHarness::new(); h.handle.push_user_message(ConversationItem::user("no sys")); assert!(h.handle.get_system_message().await.is_none()); } #[tokio::test] async fn get_system_message_returns_first_system() { let h = TestHarness::new(); h.handle .push_user_message(ConversationItem::system("You are helpful.")); h.handle.push_user_message(ConversationItem::user("hi")); let sys = h.handle.get_system_message().await.unwrap(); assert!(matches!(sys, ConversationItem::System(s) if s.content.as_ref() == "You are helpful.")); } // Subagent bootstrap regression tests // // These verify that `replace_conversation` correctly syncs the system prompt // into a ChatStateActor that was spawned before the prompt was built — the // exact sequence used by `spawn_session_actor` for subagents. #[tokio::test] async fn fresh_subagent_bootstrap_has_system_message_after_replace() { // Simulate a fresh (non-forked) subagent: actor starts with an empty conversation. // spawns with vec![] let h = TestHarness::new(); // At this point the actor has no system message, mirroring the bug. assert!(h.handle.get_system_message().await.is_none()); // Build the system prompt and inject it (mirrors acp_session.rs lines 1287-1295). let system_prompt = "You are a helpful subagent.".to_string(); let conversation = vec![ConversationItem::system(system_prompt.clone())]; h.handle.replace_conversation(conversation); // After the sync the system message must be available for compaction. let sys = h.handle.get_system_message().await.unwrap(); assert!( matches!(&sys, ConversationItem::System(s) if s.content.as_ref() == system_prompt), "expected system prompt after replace_conversation, got {sys:?}" ); } #[tokio::test] async fn forked_subagent_bootstrap_replaces_parent_system_message() { let parent_prompt = "You are the parent agent.".to_string(); let child_prompt = "You are the child subagent.".to_string(); // Simulate a forked subagent: actor starts with the parent's conversation // which already contains the parent's system message. let h = TestHarness::with_conversation(vec![ ConversationItem::system(parent_prompt.clone()), ConversationItem::user("hello"), ConversationItem::assistant("hi"), ]); // Verify the parent's system message is present initially. let sys = h.handle.get_system_message().await.unwrap(); assert!(matches!(&sys, ConversationItem::System(s) if s.content.as_ref() == parent_prompt)); // Build the child's system prompt and replace the first System item // (mirrors acp_session.rs lines 1287-1295). let mut conversation = vec![ ConversationItem::system(parent_prompt), ConversationItem::user("hello"), ConversationItem::assistant("hi"), ]; if let Some(ConversationItem::System(sys)) = conversation.first_mut() { sys.content = child_prompt.clone().into(); } h.handle.replace_conversation(conversation); // The system message must now be the child's prompt, not the parent's. let sys = h.handle.get_system_message().await.unwrap(); assert!( matches!(&sys, ConversationItem::System(s) if s.content.as_ref() == child_prompt), "expected child prompt after replace_conversation, got {sys:?}" ); // The rest of the conversation must be preserved. let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 3); } // In-memory retained pruning tests (PR3) /// Helper: push N complete turns (user + assistant + tool-result) so the /// conversation grows to a predictable length. async fn push_turns(handle: &crate::handle::ChatStateHandle, turns: usize, content_len: usize) { for i in 0..turns { handle.push_user_message(ConversationItem::user(format!("q{i}"))); handle.increment_prompt_index(); handle.push_assistant_response(ConversationItem::assistant(format!("a{i}"))); handle.push_tool_result(ConversationItem::tool_result( format!("call_{i}"), "x".repeat(content_len), )); } // Sync point let _ = handle.get_conversation_len().await; } /// After fewer turns than `hard_clear_age_turns` the retained conversation /// must not be touched (fast-exit path). #[tokio::test] async fn prune_retained_no_op_when_session_is_young() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let config = PruningConfig { hard_clear_age_turns: 10, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); // Push 5 turns — below the hard_clear_age_turns threshold of 10. push_turns(&handle, 5, 10_000).await; // All tool results must be untouched. let conv = handle.get_conversation().await; for item in &conv { if let ConversationItem::ToolResult(tr) = item { assert_eq!( tr.content.len(), 10_000, "young session: tool result must not be pruned" ); } } } /// After `hard_clear_age_turns + 1` turns, tool results from the oldest /// turns must be hard-cleared in the in-memory conversation. #[tokio::test] async fn prune_retained_hard_clears_old_tool_results() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let config = PruningConfig { hard_clear_age_turns: 5, keep_last_n_turns: 2, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); // Push 8 turns — turns 0..2 will be older than hard_clear_age_turns=5. push_turns(&handle, 8, 5_000).await; let conv = handle.get_conversation().await; // Turns are laid out as [User, Assistant, ToolResult] * 8. // ToolResult for turn 0 is at index 2. let oldest_tr = match &conv[2] { ConversationItem::ToolResult(tr) => tr.content.clone(), other => panic!("expected ToolResult at index 2, got {other:?}"), }; assert_eq!( oldest_tr.as_ref(), "[Tool result omitted — too old]", "oldest tool result must be hard-cleared" ); // Recent turns (6, 7) must be untouched. let recent_tr_6 = match &conv[6 * 3 + 2] { ConversationItem::ToolResult(tr) => tr.content.clone(), other => panic!("expected ToolResult, got {other:?}"), }; assert_eq!( recent_tr_6.len(), 5_000, "recent tool result must NOT be pruned" ); } /// Pruning is a no-op when `enabled = false`. #[tokio::test] async fn prune_retained_disabled_is_noop() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let config = PruningConfig { enabled: false, hard_clear_age_turns: 3, keep_last_n_turns: 1, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); push_turns(&handle, 6, 10_000).await; let conv = handle.get_conversation().await; for item in &conv { if let ConversationItem::ToolResult(tr) = item { assert_eq!( tr.content.len(), 10_000, "disabled pruning: tool result must not be pruned" ); } } } /// Retained conversation size is bounded after many turns: old tool results /// are replaced with a short placeholder, not retained in full. #[tokio::test] async fn prune_retained_bounds_long_session_footprint() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; // enough turns to clear many old tool results const TURNS: usize = 50; // 50 KB per tool result const CONTENT_LEN: usize = 50_000; const PLACEHOLDER_LEN: usize = "[Tool result omitted — too old]".len(); let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let config = PruningConfig { hard_clear_age_turns: 5, keep_last_n_turns: 2, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); push_turns(&handle, TURNS, CONTENT_LEN).await; let conv = handle.get_conversation().await; // Count how many tool results are still at full size vs. cleared. let (full, cleared) = conv.iter().fold((0usize, 0usize), |(f, c), item| { if let ConversationItem::ToolResult(tr) = item { if tr.content.len() == PLACEHOLDER_LEN { (f, c + 1) } else { (f + 1, c) } } else { (f, c) } }); // Most tool results must be cleared; only the very recent ones (≤ keep_last_n_turns) are kept. assert!( cleared > full, "majority of old tool results must be hard-cleared (cleared={cleared}, full={full})" ); // Approximate in-memory footprint of tool results should be much less than // the naive uncompressed size. let actual_tr_bytes: usize = conv .iter() .filter_map(|i| { if let ConversationItem::ToolResult(tr) = i { Some(tr.content.len()) } else { None } }) .sum(); let naive_bytes = TURNS * CONTENT_LEN; assert!( actual_tr_bytes < naive_bytes / 5, "retained tool-result bytes ({actual_tr_bytes}) must be << naive ({naive_bytes})" ); } /// Rewind (truncate_to_prompt_index) still produces the correct item count /// after in-memory pruning has cleared some old tool results. #[tokio::test] async fn prune_retained_rewind_still_correct() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; let (mock, mut rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); // Aggressive pruning to make in-memory pruning fire quickly. let config = PruningConfig { hard_clear_age_turns: 3, keep_last_n_turns: 1, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); // Push 6 turns: [User, Assistant, ToolResult] * 6 = 18 items. push_turns(&handle, 6, 1_000).await; // Rewind to prompt index 3 (keep turns 0..3 = 9 items). handle.truncate_to_prompt_index(3).await; let conv = handle.get_conversation().await; assert_eq!( conv.len(), 9, "after rewind to prompt_index=3, should have 9 items (3 turns * 3 items)" ); let idx = handle.get_prompt_index().await; assert_eq!(idx, 3); // Verify we have the right item types: (User, Assistant, ToolResult) * 3. for turn in 0..3 { assert!( matches!(&conv[turn * 3], ConversationItem::User(_)), "item[{turn}*3] should be User" ); assert!( matches!(&conv[turn * 3 + 1], ConversationItem::Assistant(_)), "item[{turn}*3+1] should be Assistant" ); assert!( matches!(&conv[turn * 3 + 2], ConversationItem::ToolResult(_)), "item[{turn}*3+2] should be ToolResult" ); } // Drain to avoid memory leak warning. rx.drain(); } /// Regression test: synthetic `User` items injected mid-turn (e.g. system /// warnings) must NOT cause old tool results to be cleared earlier than /// `hard_clear_age_turns` real turns. /// /// Scenario: 3 real turns with tool results, then 2 synthetic User items /// injected without incrementing `prompt_index`, then a 4th real turn. /// With `hard_clear_age_turns = 5`, none of the 4 tool results should be /// cleared yet (the oldest is only 4 real turns old after the 4th real turn /// starts, since prompt_index is 4 at prune time). #[tokio::test] async fn prune_retained_synthetic_user_does_not_advance_age() { use crate::actor::ChatStateActor; use crate::persistence::MockChatPersistence; use crate::types::PruningConfig; let (mock, _rx) = MockChatPersistence::new(); let (event_tx, _) = tokio::sync::mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); let config = PruningConfig { hard_clear_age_turns: 5, keep_last_n_turns: 1, ..Default::default() }; let handle = ChatStateActor::spawn_with_pruning( vec![], test_config(), config, Box::new(mock), event_tx, token, ); // Three real turns, each with a large tool result. for i in 0..3usize { handle.push_user_message(ConversationItem::user(format!("real q{i}"))); // prompt_index = i+1 handle.increment_prompt_index(); handle.push_assistant_response(ConversationItem::assistant(format!("a{i}"))); handle.push_tool_result(ConversationItem::tool_result( format!("call_{i}"), "x".repeat(10_000), )); } // Two synthetic User items injected mid-turn (e.g. doom-loop warnings). // These do NOT call increment_prompt_index — prompt_index stays at 3. handle.push_user_message(ConversationItem::user("⚠️ doom-loop warning 1")); handle.push_user_message(ConversationItem::user("⚠️ doom-loop warning 2")); // Fourth real turn starts: prompt_index → 4, pruning fires inside push_user_message. handle.push_user_message(ConversationItem::user("real q3")); // prompt_index = 4 handle.increment_prompt_index(); // Sync let conv = handle.get_conversation().await; // None of the 3 original tool results should be cleared: // oldest real age = 3 turns (turn 0 is 3 real turns ago), threshold = 5. // Without the synthetic-count compensation, the 2 synthetic User items // would make turn 0's TR appear age 5, causing premature clearing. for item in &conv { if let ConversationItem::ToolResult(tr) = item { assert_ne!( tr.content.as_ref(), "[Tool result omitted — too old]", "tool result must NOT be cleared: oldest is only 3 real turns old \ (threshold is 5), synthetic user injections must not advance age" ); } } } #[tokio::test] async fn get_last_model_metadata_returns_both_fields() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hi"), ConversationItem::Assistant(kigi_sampling_types::AssistantItem { content: "hello".into(), tool_calls: vec![], model_id: Some("kigi-4.5".into()), model_fingerprint: Some("fp_abc123".into()), reasoning_effort: None, }), ]); let meta = h.handle.get_last_model_metadata().await; assert_eq!(meta.resolved_model_id.as_deref(), Some("kigi-4.5")); assert_eq!(meta.model_fingerprint.as_deref(), Some("fp_abc123")); } #[tokio::test] async fn get_last_model_metadata_returns_default_when_no_assistant() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hi"), ]); let meta = h.handle.get_last_model_metadata().await; assert!(meta.resolved_model_id.is_none()); assert!(meta.model_fingerprint.is_none()); } /// Reproduce: after compaction replaces the conversation, `get_sampling_config` /// must still return the original model/context_window/api_backend. The /// `SamplingConfig` lives in a separate field — `replace_conversation` must /// not touch it. #[tokio::test] async fn sampling_config_survives_compaction_replacement() { use kigi_sampling_types::ApiBackend; let config = SamplingConfig { base_url: "https://api.example.com".to_string(), model: "kigi".to_string(), max_completion_tokens: None, temperature: Some(0.7), top_p: Some(0.95), api_backend: ApiBackend::Responses, chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, }; let h = TestHarness::with_config( vec![ ConversationItem::system("You are a helpful assistant."), ConversationItem::user("fix the bug"), ConversationItem::Assistant(kigi_sampling_types::AssistantItem { content: "I'll fix it.".into(), tool_calls: vec![], model_id: Some("kigi-4.5".into()), model_fingerprint: Some("fp_abc123".into()), reasoning_effort: None, }), ], config, ); // Pre-compaction: everything correct. let pre = h.handle.get_sampling_config().await.unwrap(); assert_eq!(pre.model, "kigi"); assert_eq!(pre.context_window.get(), 500_000); assert_eq!(pre.api_backend, ApiBackend::Responses); let pre_meta = h.handle.get_last_model_metadata().await; assert_eq!(pre_meta.resolved_model_id.as_deref(), Some("kigi-4.5")); assert_eq!(pre_meta.model_fingerprint.as_deref(), Some("fp_abc123")); // Simulate compaction: replace conversation with compacted history. // The compacted history has system + user summary, NO AssistantItem. h.handle.replace_conversation_for_compaction(vec![ ConversationItem::system("You are a helpful assistant."), ConversationItem::user("Compaction summary: user asked to fix a bug..."), ]); // Post-compaction: SamplingConfig MUST be preserved. let post = h.handle.get_sampling_config().await.unwrap(); assert_eq!(post.model, "kigi", "BUG: model changed after compaction"); assert_eq!( post.context_window.get(), 500_000, "BUG: context_window dropped to default after compaction" ); assert_eq!( post.api_backend, ApiBackend::Responses, "BUG: api_backend switched to ChatCompletions after compaction" ); // Post-compaction: model metadata is LOST (no AssistantItem in compacted history). // This is the visible symptom -- fingerprint/hash disappears from /session-info. let post_meta = h.handle.get_last_model_metadata().await; assert!( post_meta.resolved_model_id.is_none(), "resolved_model_id should be None after compaction (no AssistantItem)" ); assert!( post_meta.model_fingerprint.is_none(), "model_fingerprint should be None after compaction (no AssistantItem)" ); } /// After compaction, the `build_session_info` display path uses /// `get_sampling_config().model` as the source-of-truth model slug. /// If that model slug is e.g. "kigi" and not in the ModelState /// catalog with a display name, the pager shows the raw slug. This /// test verifies the pager's `current_model_name()` behavior when the /// model ID doesn't match any catalog entry. #[tokio::test] async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() { let config = SamplingConfig { base_url: "https://api.example.com".to_string(), model: "kigi".to_string(), max_completion_tokens: None, temperature: Some(0.7), top_p: Some(0.95), api_backend: Default::default(), chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, }; let h = TestHarness::with_config( vec![ ConversationItem::system("sys"), ConversationItem::user("task"), ConversationItem::Assistant(kigi_sampling_types::AssistantItem { content: "done".into(), tool_calls: vec![], model_id: Some("kigi-4.5".into()), model_fingerprint: Some("fp_acd3142484d3ad6f".into()), reasoning_effort: None, }), ], config, ); // Before compaction: metadata present. let meta = h.handle.get_last_model_metadata().await; assert_eq!(meta.resolved_model_id.as_deref(), Some("kigi-4.5")); assert_eq!( meta.model_fingerprint.as_deref(), Some("fp_acd3142484d3ad6f") ); // Compaction replaces conversation — no AssistantItem in compacted history. h.handle.replace_conversation_for_compaction(vec![ ConversationItem::system("sys"), ConversationItem::user("compacted summary"), ]); // Metadata gone. let meta = h.handle.get_last_model_metadata().await; assert!(meta.resolved_model_id.is_none()); assert!(meta.model_fingerprint.is_none()); // Simulate next turn: model responds and metadata is restored. h.handle .push_user_message(ConversationItem::user("next task")); h.handle .push_assistant_response(ConversationItem::Assistant( kigi_sampling_types::AssistantItem { content: "working on it".into(), tool_calls: vec![], model_id: Some("kigi-4.5".into()), model_fingerprint: Some("fp_acd3142484d3ad6f".into()), reasoning_effort: None, }, )); // Metadata recovered. let meta = h.handle.get_last_model_metadata().await; assert_eq!(meta.resolved_model_id.as_deref(), Some("kigi-4.5")); assert_eq!( meta.model_fingerprint.as_deref(), Some("fp_acd3142484d3ad6f") ); } /// Verify that a context_window downgrade via `update_sampling_config` /// causes `check_auto_compact_needed` to fire when token usage already /// exceeds the new (smaller) window. /// /// This exercises the actor's arithmetic: if anything (model switch, /// session resume, etc.) shrinks the context window below accumulated /// token usage, auto-compact must trigger. /// /// Note: `handle_model_metadata_update` in acp_session.rs now blocks /// response-header downgrades (only upgrades accepted), so this path /// is mainly reachable via model switches. The actor itself still /// accepts any value via `update_sampling_config` — the guard lives /// in the session layer. #[tokio::test] async fn context_window_downgrade_triggers_auto_compact() { use kigi_sampling_types::ApiBackend; // Initial config: 500k context, Responses backend (matches kigi-4.5) let config = SamplingConfig { base_url: "https://byok.example/v1".to_string(), model: "kigi-4.5".to_string(), max_completion_tokens: None, temperature: Some(0.7), top_p: Some(0.95), api_backend: ApiBackend::Responses, chat_compat: Default::default(), extra_headers: Default::default(), context_window: NonZeroU64::new(500_000).unwrap(), reasoning_effort: None, stream_tool_calls: None, }; let h = TestHarness::with_config(vec![], config); // Simulate 217k tokens of conversation (matching turn 587's total_tokens) h.handle.record_token_usage(217_000); // Pre-downgrade: 217k / 500k = 43% — well under auto-compact threshold let pre = h.handle.get_sampling_config().await.unwrap(); assert_eq!(pre.context_window.get(), 500_000); let trigger = h.handle.check_auto_compact_needed(85).await; assert!( trigger.is_none(), "should NOT trigger auto-compact at 43% (217k/500k)" ); // Simulate a context_window downgrade (e.g. model switch, response // header from cli-chat-proxy, or stale prefetched model list). let mut downgraded = pre.clone(); downgraded.context_window = NonZeroU64::new(128_000).unwrap(); h.handle.update_sampling_config(downgraded); // Post-downgrade: 217k / 128k = 170% — massively over capacity let post = h.handle.get_sampling_config().await.unwrap(); assert_eq!( post.context_window.get(), 128_000, "context_window should be overwritten by update_sampling_config" ); assert_eq!(post.model, "kigi-4.5", "model slug must not change"); assert_eq!( post.api_backend, ApiBackend::Responses, "api_backend must not change" ); let trigger = h.handle.check_auto_compact_needed(85).await; assert!( trigger.is_some(), "MUST trigger auto-compact at 170% (217k/128k) — this is the bug!" ); let info = trigger.unwrap(); assert_eq!(info.context_window, NonZeroU64::new(128_000).unwrap()); assert_eq!(info.total_tokens, 217_000); // utilization_percent is u8 so it caps at 255, but we just need >85 assert!( info.utilization_percent > 85, "utilization should be well above threshold but got {}%", info.utilization_percent ); } // KV Cache Prefix Stability Tests // // These test `build_conversation_request()` output prefix stability through // the full pipeline -- pruning, memory injection, image pruning, snapshot // restore. Prefix stability within a compaction epoch is the invariant that // keeps the inference engine's prefix / KV cache hitting. The sibling-Reasoning refactor // deleted the placeholder/splice machinery these tests earlier had to work // around. // // These target the refactored sibling-Reasoning shape: // - No `__RAW_OUTPUT_PLACEHOLDER__` sentinels // - No `extract_raw_input_items()` / `splice_raw_input_items()` // - Reasoning lives as `ConversationItem::Reasoning(rs::ReasoningItem)` // siblings; the From<&ConversationRequest> for rs::CreateResponse impl // emits them inline in `input` order. /// Serialize a ConversationRequest using only the public /// `From<&ConversationRequest> for rs::CreateResponse` trait impl. /// /// After the sibling-Reasoning refactor there is no placeholder/splice dance: the `Vec` /// produced by the From impl is directly the wire shape (modulo /// `patch_reasoning_text_types` which only stamps a `type` field on nested /// reasoning content blocks and does not reorder items). fn serialize_via_public_api(req: &kigi_sampling_types::ConversationRequest) -> serde_json::Value { use kigi_sampling_types::rs; let create_response: rs::CreateResponse = req.into(); let mut body = serde_json::to_value(&create_response).unwrap(); kigi_sampling_types::patch_reasoning_text_types(&mut body); // Sanity guard: the placeholder string from the pre-refactor design // must never appear in the serialized output. If a future change // re-introduces a stringly-typed splice, this catches it. let body_str = serde_json::to_string(&body).unwrap(); assert!( !body_str.contains("__RAW_OUTPUT_PLACEHOLDER_"), "placeholder sentinel must never appear in serialized output \ post-sibling-Reasoning refactor" ); body } /// Assert that request N's serialized input is a byte-stable prefix of /// request N+1's. fn assert_prefix_stable_pair( base: &kigi_sampling_types::ConversationRequest, extended: &kigi_sampling_types::ConversationRequest, label: &str, ) { let base_body = serialize_via_public_api(base); let ext_body = serialize_via_public_api(extended); let base_input = base_body["input"].as_array().unwrap(); let ext_input = ext_body["input"].as_array().unwrap(); assert!( ext_input.len() >= base_input.len(), "{label}: extended request has fewer input items ({}) than base ({})", ext_input.len(), base_input.len(), ); assert_eq!( &ext_input[..base_input.len()], base_input.as_slice(), "{label}: prefix broken. Base has {} items, extended has {}. \ First divergence at index {}", base_input.len(), ext_input.len(), base_input .iter() .zip(ext_input.iter()) .position(|(a, b)| a != b) .unwrap_or(base_input.len()), ); } /// Build a sibling Reasoning item with the given id and (optionally) /// encrypted_content. Replaces the pre-refactor "AssistantItem.raw_output" /// helper. fn reasoning_sibling(id: &str, encrypted: Option<&str>) -> ConversationItem { use kigi_sampling_types::rs; ConversationItem::Reasoning(rs::ReasoningItem { id: id.to_string(), summary: vec![rs::SummaryPart::SummaryText(rs::SummaryTextContent { text: format!("thinking for {id}"), })], content: None, encrypted_content: encrypted.map(str::to_owned), status: None, }) } /// Basic multi-turn prefix stability through build_request(). /// Each turn adds a user message + assistant response; the serialized /// input from turn N must be a prefix of turn N+1. #[tokio::test] async fn prefix_stable_across_user_assistant_turns() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("You are a helpful assistant."), ConversationItem::user("Hello"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("Hi there!")); h.handle .push_user_message(ConversationItem::user("How are you?")); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "turn 1 -> turn 2"); h.handle .push_assistant_response(ConversationItem::assistant("I'm well!")); h.handle.push_user_message(ConversationItem::user("Great")); let req3 = h .handle .build_request(vec![], None, false, None, "c".into(), "r3".into()) .await .unwrap(); assert_prefix_stable_pair(&req2, &req3, "turn 2 -> turn 3"); } /// Prefix stability when memory reminders are injected. /// Once a memory reminder is established, subsequent requests with the /// SAME reminder must produce stable prefixes. #[tokio::test] async fn prefix_stable_with_consistent_memory_injection() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("You are helpful."), ConversationItem::user("Hello"), ]); let req1 = h .handle .build_request( vec![], Some("Remember: user likes Rust".to_string()), false, None, "c".into(), "r1".into(), ) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("Hi!")); h.handle .push_user_message(ConversationItem::user("Tell me more")); let req2 = h .handle .build_request( vec![], Some("Remember: user likes Rust".to_string()), false, None, "c".into(), "r2".into(), ) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "memory-injected turn 1 -> turn 2"); } /// Prefix stability with Reasoning siblings (encrypted reasoning) through /// the full build_request pipeline. This is the structural equivalent of the /// earlier `prefix_stable_with_raw_output_through_build_request` test -- /// it exercises the exact code path that caused a prefix-instability incident, /// but on the post-refactor data model where reasoning rides as a typed sibling /// rather than an `AssistantItem.raw_output` blob. #[tokio::test] async fn prefix_stable_with_reasoning_siblings_through_build_request() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("u1"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); // Push Reasoning sibling + Assistant (the new ordering produced by // `response_to_conversation_items`: Reasoning before Assistant). h.handle .push_tool_result(reasoning_sibling("r_abc", Some("enc1"))); h.handle .push_assistant_response(ConversationItem::assistant("response 1")); h.handle.push_user_message(ConversationItem::user("u2")); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "Reasoning sibling turn 1 -> turn 2"); // Push another turn's Reasoning + Assistant h.handle .push_tool_result(reasoning_sibling("r_def", Some("enc2"))); h.handle .push_assistant_response(ConversationItem::assistant("response 2")); h.handle.push_user_message(ConversationItem::user("u3")); let req3 = h .handle .build_request(vec![], None, false, None, "c".into(), "r3".into()) .await .unwrap(); assert_prefix_stable_pair( &req2, &req3, "Reasoning sibling turn 2 -> turn 3 (cross-turn prefix-stability regression)", ); } /// Tool definitions are in the `tools` field, not the `input` array. /// Changing the tool set between requests must not affect the input prefix. #[tokio::test] async fn prefix_stable_after_tool_schema_change() { use kigi_sampling_types::ToolSpec; let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hello"), ]); let tools_v1 = vec![ToolSpec { name: "read_file".to_string(), description: Some("Read a file".to_string()), parameters: serde_json::json!({"type": "object"}), }]; let req1 = h .handle .build_request(tools_v1, None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("read it")); h.handle .push_user_message(ConversationItem::user("now edit")); let tools_v2 = vec![ ToolSpec { name: "read_file".to_string(), description: Some("Read a file".to_string()), parameters: serde_json::json!({"type": "object"}), }, ToolSpec { name: "edit_file".to_string(), description: Some("Edit a file".to_string()), parameters: serde_json::json!({"type": "object"}), }, ]; let req2 = h .handle .build_request(tools_v2, None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "tool schema v1 -> v2"); } /// Model switch between turns: model is in request metadata, not input /// items. Input prefix must remain stable. #[tokio::test] async fn prefix_stable_after_model_switch() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hello"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("hi")); h.handle .push_user_message(ConversationItem::user("continue")); let new_config = SamplingConfig { model: "kigi-3-mini".to_string(), ..test_config() }; h.handle.update_sampling_config(new_config); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "model switch"); } /// Synthetic user messages (doom loop warning, auto-continue) are /// appended -- not inserted before existing items -- so the prefix /// must remain stable. #[tokio::test] async fn prefix_stable_with_synthetic_user_messages() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hello"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("hi")); h.handle .push_user_message(ConversationItem::system_reminder("stop looping")); h.handle .push_assistant_response(ConversationItem::assistant("ok")); h.handle .push_user_message(ConversationItem::auto_continue("keep going")); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "with synthetic user messages"); } /// Prefix stability after size-gated image eviction. Once the serialized body /// nears the 50 MB ceiling, old user turns' images are replaced with text /// placeholders on the request clone -- text items before the evicted region /// must stay prefix-stable in their relative ordering. The image here is sized /// past `IMAGE_COMPACT_TRIGGER_BYTES` so the eviction actually fires. #[tokio::test] async fn prefix_stable_after_image_pruning() { use kigi_sampling_types::ContentPart; // Large enough that the serialized body crosses the compaction trigger. let big_image_url = format!( "data:image/png;base64,{}", "A".repeat(crate::actor::request_builder::IMAGE_COMPACT_TRIGGER_BYTES) ); let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::User(kigi_sampling_types::UserItem { content: vec![ ContentPart::Text { text: "look at this image".into(), }, ContentPart::Image { url: big_image_url.into(), }, ], synthetic_reason: None, ..Default::default() }), ConversationItem::assistant("I see it"), ConversationItem::user("u2"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("a2")); h.handle .push_user_message(ConversationItem::User(kigi_sampling_types::UserItem { content: vec![ ContentPart::Text { text: "new image".into(), }, ContentPart::Image { url: "data:image/png;base64,newImageData".into(), }, ], synthetic_reason: None, ..Default::default() })); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); // Image stripping mutates the old user turn's content, so full // byte-level prefix stability cannot hold at that item. We verify: // 3. Text items appear in the same relative order let body1 = serialize_via_public_api(&req1); let body2 = serialize_via_public_api(&req2); let input1 = body1["input"].as_array().unwrap(); let input2 = body2["input"].as_array().unwrap(); assert_eq!( input1[0], input2[0], "system prompt must be preserved after image pruning" ); assert!( input2.len() > input1.len(), "extended request must have more items" ); let extract_text_items = |input: &[serde_json::Value]| -> Vec { input .iter() .filter_map(|v| { let role = v.get("role").and_then(|r| r.as_str())?; let content = v.get("content").and_then(|c| c.as_str())?; Some(format!("{role}:{content}")) }) .collect() }; let texts1 = extract_text_items(input1); let texts2 = extract_text_items(input2); let mut idx2 = 0; for t1 in &texts1 { while idx2 < texts2.len() && &texts2[idx2] != t1 { idx2 += 1; } assert!( idx2 < texts2.len(), "text item {t1:?} from req1 must appear in req2 in the same order" ); idx2 += 1; } } /// Regression for the image cache-miss bug: with normal small images (well /// under the 50 MB ceiling), an old user turn's image is preserved across /// turns instead of being rewritten to a placeholder. Rewriting old images on /// every turn busted the KV-cache prefix (the over-aggressive earlier /// behavior this size-gate replaces). #[tokio::test] async fn build_request_preserves_small_old_images() { use kigi_sampling_types::{ContentPart, UserItem}; let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::User(UserItem { content: vec![ ContentPart::Text { text: "look at this".into(), }, ContentPart::Image { url: "data:image/png;base64,iVBORw0KGgo=".into(), }, ], synthetic_reason: None, ..Default::default() }), ConversationItem::assistant("I see it"), ConversationItem::user("follow up question"), ]); let req = h .handle .build_request(vec![], None, false, None, "c".into(), "r".into()) .await .unwrap(); // The old user turn's image must survive (small payload, far under 50 MB), // so the KV-cache prefix stays byte-stable instead of being rewritten. let image_retained = req.items.iter().any(|item| { matches!(item, ConversationItem::User(u) if u.content.iter().any(|p| matches!(p, ContentPart::Image { .. }))) }); assert!( image_retained, "a small old image must be preserved, not stripped to a placeholder" ); } /// Prefix stability after tool result pruning. When context utilization /// exceeds 50%, old tool results are soft-trimmed or hard-cleared, but /// this happens on a clone -- items outside the pruned region must /// remain identical. #[tokio::test] async fn prefix_stable_after_tool_result_pruning() { let h = TestHarness::with_context_window(10_000); h.handle.push_user_message(ConversationItem::system("sys")); h.handle.push_user_message(ConversationItem::user("q1")); h.handle .push_assistant_response(ConversationItem::assistant("a1")); h.handle .push_tool_result(ConversationItem::tool_result("c1", "x".repeat(500))); h.handle.push_user_message(ConversationItem::user("q2")); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("a2")); h.handle .push_tool_result(ConversationItem::tool_result("c2", "y".repeat(500))); h.handle.push_user_message(ConversationItem::user("q3")); // > 50% of 10k context h.handle.record_token_usage(6000); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); let body1 = serialize_via_public_api(&req1); let body2 = serialize_via_public_api(&req2); let input1 = body1["input"].as_array().unwrap(); let input2 = body2["input"].as_array().unwrap(); assert_eq!( input1[0], input2[0], "system prompt must be stable after pruning" ); assert!( input2.len() >= input1.len(), "pruned request should still have >= items" ); let extract_user_texts = |input: &[serde_json::Value]| -> Vec { input .iter() .filter_map(|v| { if v.get("role").and_then(|r| r.as_str()) == Some("user") { v.get("content").and_then(|c| c.as_str()).map(String::from) } else { None } }) .collect() }; let users1 = extract_user_texts(input1); let users2 = extract_user_texts(input2); let mut idx2 = 0; for u1 in &users1 { while idx2 < users2.len() && &users2[idx2] != u1 { idx2 += 1; } assert!( idx2 < users2.len(), "user message {u1:?} from req1 must appear in req2 in same order" ); idx2 += 1; } } /// `BackendToolCall` items (web search) must serialize stably across /// turns. Once emitted, a backend tool call sits at a fixed position /// in the conversation list and the wire input. #[tokio::test] async fn prefix_stable_with_backend_tool_calls() { use kigi_sampling_types::{BackendToolCallItem, BackendToolKind, rs}; let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("search for capybaras"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("Found info about capybaras")); h.handle .push_tool_result(ConversationItem::BackendToolCall(BackendToolCallItem { kind: BackendToolKind::WebSearch(rs::WebSearchToolCall { id: "ws_capybara".to_string(), status: rs::WebSearchToolCallStatus::Completed, action: rs::WebSearchToolCallAction::Search(rs::WebSearchActionSearch { query: "capybara facts".to_string(), sources: Some(vec![]), }), }), })); h.handle .push_user_message(ConversationItem::user("tell me more")); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req2, "with backend tool calls"); h.handle .push_assistant_response(ConversationItem::assistant("More capybara info")); h.handle.push_user_message(ConversationItem::user("thanks")); let req3 = h .handle .build_request(vec![], None, false, None, "c".into(), "r3".into()) .await .unwrap(); assert_prefix_stable_pair(&req2, &req3, "backend tool call stable across 3 turns"); } /// Snapshot/restore must preserve prefix stability within the same /// compaction epoch. After restoring a snapshot, build_request output /// should match what it was before the restore. #[tokio::test] async fn prefix_stable_after_session_resume() { let h = TestHarness::with_conversation(vec![ ConversationItem::system("sys"), ConversationItem::user("hello"), ]); let req1 = h .handle .build_request(vec![], None, false, None, "c".into(), "r1".into()) .await .unwrap(); h.handle .push_assistant_response(ConversationItem::assistant("hi")); h.handle.push_user_message(ConversationItem::user("q2")); let req2 = h .handle .build_request(vec![], None, false, None, "c".into(), "r2".into()) .await .unwrap(); let snapshot = h.handle.snapshot().await.unwrap(); h.handle .replace_conversation(vec![ConversationItem::system("different")]); h.handle.restore_snapshot(snapshot); let req3 = h .handle .build_request(vec![], None, false, None, "c".into(), "r3".into()) .await .unwrap(); assert_prefix_stable_pair(&req1, &req3, "after session resume"); let body2 = serialize_via_public_api(&req2); let body3 = serialize_via_public_api(&req3); let input2 = body2["input"].as_array().unwrap(); let input3 = body3["input"].as_array().unwrap(); assert_eq!( input2, input3, "restored snapshot must produce identical request items" ); } // Out-of-band history repair (kigi/session/repair) /// Bricked-session shape: an orphaned tool result survives load (the eager /// repairs only fix dangling calls) and 400s on every request. The /// out-of-band `RepairHistory` command must strip it and persist the fix. #[tokio::test] async fn repair_history_command_strips_orphan_and_persists() { use kigi_sampling_types::ToolCall; let corrupted = vec![ ConversationItem::system("sys"), ConversationItem::user("prompt"), // ← assistant owning call_LOST is missing (skipped corrupt line) ConversationItem::tool_result("call_LOST", "orphaned"), ConversationItem::assistant_tool_calls(vec![ToolCall { id: "call_OK".into(), name: "read_file".to_string(), arguments: "{}".into(), }]), ConversationItem::tool_result("call_OK", "fine"), ]; let mut h = TestHarness::with_conversation(corrupted); // Load-time repairs leave the orphan in place (the bug under test). assert_eq!(h.handle.get_conversation().await.len(), 5); h.drain_persistence(); // Dry run: reports the orphan, mutates nothing, persists nothing. let report = h.handle.repair_history(true, None).await.unwrap().unwrap(); assert!(report.changed()); assert_eq!(report.stripped_tool_result_ids, vec!["call_LOST"]); assert_eq!(h.handle.get_conversation().await.len(), 5); assert!(h.drain_persistence().is_empty(), "dry run must not persist"); // Real repair: orphan stripped, fix persisted via a full history replace. let report = h.handle.repair_history(false, None).await.unwrap().unwrap(); assert!(report.changed()); assert_eq!(report.stripped_tool_result_ids, vec!["call_LOST"]); let conv = h.handle.get_conversation().await; assert_eq!(conv.len(), 4); assert!(!conv.iter().any(|i| matches!( i, ConversationItem::ToolResult(tr) if tr.tool_call_id == "call_LOST" ))); let replaced = h .drain_persistence() .into_iter() .find_map(|r| match r { PersistenceRecord::ReplaceHistory(items) => Some(items), _ => None, }) .expect("repair must persist a full history replace"); assert_eq!(replaced.len(), 4); // Idempotent: a second repair reports no changes and persists nothing. let report = h.handle.repair_history(false, None).await.unwrap().unwrap(); assert!(!report.changed()); assert!(h.drain_persistence().is_empty()); } /// A repair command processed while the shared turn-active flag is set must /// be refused without mutating or persisting anything (the in-actor check /// closes the race with a turn starting after the caller's own check). #[tokio::test] async fn repair_history_command_refused_while_turn_active() { let corrupted = vec![ ConversationItem::user("prompt"), ConversationItem::tool_result("call_ORPHAN", "orphaned"), ]; let mut h = TestHarness::with_conversation(corrupted); h.drain_persistence(); let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); let result = h .handle .repair_history(false, Some(flag.clone())) .await .unwrap(); assert!(matches!(result, Err(crate::commands::RepairHistoryBlocked))); // Nothing was mutated or persisted. assert_eq!(h.handle.get_conversation().await.len(), 2); assert!(h.drain_persistence().is_empty()); // Once the turn ends, the same call succeeds. flag.store(false, std::sync::atomic::Ordering::SeqCst); let report = h .handle .repair_history(false, Some(flag)) .await .unwrap() .unwrap(); assert_eq!(report.stripped_tool_result_ids, vec!["call_ORPHAN"]); }