Files
Kigi-CLI/crates/codegen/kigi-chat-state/src/actor/tests.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

4397 lines
147 KiB
Rust

//! 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<ChatStateEvent>,
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<ConversationItem>) -> 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<ConversationItem>, 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<ChatStateEvent> {
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<PersistenceRecord> {
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("<memory-context>\nRemember this\n</memory-context>".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::<str>::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<rs::InputItem>`
/// 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<String> {
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<String> {
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"]);
}