use chrono::Utc;
use phi_core::context::*;
use phi_core::session::{LoopRecord, LoopStatus, Session, SessionFormation, SessionScope};
use phi_core::ContinuationKind;
use phi_core::*;
fn make_loop_record(loop_id: &str, num_turns: u32, parent: Option<&str>) -> LoopRecord {
let mut messages = Vec::new();
for t in 0..num_turns {
let tid = Some(TurnId {
loop_id: loop_id.to_string(),
turn_index: t,
});
messages.push(
AgentMessage::from(Message::user(format!("Turn {} question", t)))
.with_turn_id(tid.clone()),
);
messages.push(
AgentMessage::from(Message::Assistant {
content: vec![Content::Text {
text: format!("Turn {} answer with some content that is meaningful", t),
}],
stop_reason: StopReason::Stop,
model: "test".into(),
provider: "test".into(),
usage: Usage::default(),
timestamp: 0,
error_message: None,
})
.with_turn_id(tid.clone()),
);
}
LoopRecord {
loop_id: loop_id.to_string(),
session_id: "test-session".to_string(),
agent_id: "test-agent".to_string(),
parent_loop_id: parent.map(|s| s.to_string()),
continuation_kind: ContinuationKind::Initial,
started_at: Utc::now(),
ended_at: Some(Utc::now()),
status: LoopStatus::Completed,
rejection: None,
config: None,
messages,
turns: Vec::new(),
usage: Usage::default(),
metadata: None,
events: Vec::new(),
children_loop_ids: Vec::new(),
child_loop_refs: Vec::new(),
parallel_group: None,
compaction_block: None,
}
}
fn make_session(loops: Vec<LoopRecord>) -> Session {
let now = Utc::now();
Session {
session_id: "test-session".to_string(),
agent_id: "test-agent".to_string(),
created_at: now,
last_active_at: now,
formation: SessionFormation::Explicit { timestamp: now },
parent_spawn_ref: None,
scope: SessionScope::Ephemeral,
loops,
}
}
#[test]
fn test_turn_map_from_messages_groups_by_turn_id() {
let loop_id = "test.model.1";
let mut messages = Vec::new();
for _ in 0..3 {
messages.push(
AgentMessage::from(Message::user("hi")).with_turn_id(Some(TurnId {
loop_id: loop_id.into(),
turn_index: 0,
})),
);
}
for _ in 0..2 {
messages.push(
AgentMessage::from(Message::user("hello")).with_turn_id(Some(TurnId {
loop_id: loop_id.into(),
turn_index: 1,
})),
);
}
let tm = TurnMap::from_messages(&messages);
assert_eq!(tm.turn_count(), 2);
let range0 = TurnRange {
start_turn: 0,
end_turn: 0,
};
assert_eq!(tm.messages_for_range(&range0, &messages).len(), 3);
let range1 = TurnRange {
start_turn: 1,
end_turn: 1,
};
assert_eq!(tm.messages_for_range(&range1, &messages).len(), 2);
let full_range = TurnRange {
start_turn: 0,
end_turn: 1,
};
assert_eq!(tm.messages_for_range(&full_range, &messages).len(), 5);
}
#[test]
fn test_turn_map_legacy_messages_without_turn_id() {
let messages = vec![
AgentMessage::from(Message::user("no turn id 1")),
AgentMessage::from(Message::user("no turn id 2")),
];
let tm = TurnMap::from_messages(&messages);
assert_eq!(tm.turn_count(), 2);
}
#[test]
fn test_compaction_block_creation_most_recent() {
let record = make_loop_record("test.model.1", 10, None);
let config = CompactionConfig::default();
let block = DefaultBlockCompaction.compact(&record, &config, true);
assert!(block.keep_first.is_some());
assert!(block.keep_compacted.is_none()); }
#[test]
fn test_compaction_block_creation_with_middle() {
let record = make_loop_record("test.model.1", 20, None);
let config = CompactionConfig {
keep_first_turns: 2,
keep_recent_turns: 5,
max_summary_tokens: 2_000,
..CompactionConfig::default()
};
let block = DefaultBlockCompaction.compact(&record, &config, true);
assert!(block.keep_first.is_some());
let kf = block.keep_first.unwrap();
assert_eq!(kf.start_turn, 0);
assert_eq!(kf.end_turn, 1);
assert!(block.keep_compacted.is_some());
let kc = block.keep_compacted.unwrap();
assert_eq!(kc.range.start_turn, 2);
assert_eq!(kc.range.end_turn, 14);
assert!(block.keep_recent.is_some());
let kr = block.keep_recent.unwrap();
assert_eq!(kr.range.start_turn, 15);
assert_eq!(kr.range.end_turn, 19); }
#[test]
fn test_compaction_block_creation_earlier_loop() {
let record = make_loop_record("test.model.1", 10, None);
let config = CompactionConfig::default();
let block = DefaultBlockCompaction.compact(&record, &config, false);
assert!(block.keep_first.is_none());
assert!(block.keep_recent.is_none());
assert!(block.keep_compacted.is_some());
}
#[test]
fn test_build_context_falls_back_to_raw() {
let record = make_loop_record("test.model.1", 3, None);
let session = make_session(vec![record]);
let config = CompactionConfig::default();
let context = build_context_from_session(&session, "test.model.1", &config, 100_000, None);
assert_eq!(context.len(), 6);
}
#[test]
fn test_build_context_from_session_with_blocks() {
let mut record = make_loop_record("test.model.1", 20, None);
let config = CompactionConfig {
keep_first_turns: 2,
keep_recent_turns: 5,
max_summary_tokens: 2_000,
..CompactionConfig::default()
};
record.compaction_block = Some(DefaultBlockCompaction.compact(&record, &config, true));
let session = make_session(vec![record]);
let context = build_context_from_session(&session, "test.model.1", &config, 100_000, None);
assert!(context.len() < 40);
assert!(!context.is_empty());
}
#[test]
fn test_compact_session_loops_writes_earlier() {
let loop1 = make_loop_record("test.model.1", 5, None);
let loop2 = make_loop_record("test.model.2", 5, Some("test.model.1"));
let loop3 = make_loop_record("test.model.3", 10, Some("test.model.2"));
let mut session = make_session(vec![loop1, loop2, loop3]);
let config = CompactionConfig {
compaction_scope: CompactionScope::FixedCount(2),
keep_first_turns: 1,
keep_recent_turns: 3,
max_summary_tokens: 2_000,
..CompactionConfig::default()
};
compact_session_loops(
&mut session,
"test.model.3",
&DefaultBlockCompaction,
&config,
100_000,
None,
);
assert!(session
.get_loop("test.model.3")
.unwrap()
.compaction_block
.is_some());
assert!(session
.get_loop("test.model.2")
.unwrap()
.compaction_block
.is_some());
assert!(session
.get_loop("test.model.1")
.unwrap()
.compaction_block
.is_some());
}
#[test]
fn test_pct_threshold_calculation() {
let config = CompactionConfig::default(); let max_tokens = 100_000usize;
let system_tokens = 4_000usize;
let system_frac = system_tokens as f64 / max_tokens as f64;
let current_frac = 80_000f64 / max_tokens as f64;
let headroom = config.compact_at_pct - system_frac - current_frac;
assert!(headroom >= config.compact_budget_threshold_pct);
let current_frac = 82_000f64 / max_tokens as f64;
let headroom = config.compact_at_pct - system_frac - current_frac;
assert!(headroom < config.compact_budget_threshold_pct);
}
#[test]
fn test_compaction_block_serialization_roundtrip() {
let block = CompactionBlock {
keep_first: Some(TurnRange {
start_turn: 0,
end_turn: 1,
}),
keep_compacted: Some(CompactedSection {
range: TurnRange {
start_turn: 2,
end_turn: 5,
},
messages: vec![AgentMessage::from(Message::user("[Summary] test"))],
}),
keep_recent: None,
created_at: Utc::now(),
};
let json = serde_json::to_string(&block).unwrap();
let deserialized: CompactionBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block.keep_first, deserialized.keep_first);
assert!(deserialized.keep_compacted.is_some());
assert!(deserialized.keep_recent.is_none());
}
#[test]
fn test_turn_id_serialization_roundtrip() {
let msg = AgentMessage::from(Message::user("hello")).with_turn_id(Some(TurnId {
loop_id: "test.model.1".into(),
turn_index: 3,
}));
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("turnId"));
assert!(json.contains("turnIndex"));
let deserialized: AgentMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.turn_id().unwrap().turn_index, 3);
}
#[test]
fn test_turn_id_backward_compat_deserialization() {
let json = r#"{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":0}"#;
let msg: AgentMessage = serde_json::from_str(json).unwrap();
assert!(msg.turn_id().is_none());
assert_eq!(msg.role(), "user");
}
#[test]
fn test_compaction_scope_token_budget_partial() {
let loop1 = make_loop_record("test.model.1", 5, None); let loop2 = make_loop_record("test.model.2", 3, Some("test.model.1"));
let loop3 = make_loop_record("test.model.3", 3, Some("test.model.2"));
let mut session = make_session(vec![loop1, loop2, loop3]);
let config = CompactionConfig {
compaction_scope: CompactionScope::TokenBudget,
keep_first_turns: 1,
keep_recent_turns: 2,
max_summary_tokens: 2_000,
..CompactionConfig::default()
};
let small_budget =
phi_core::context::total_tokens(&session.get_loop("test.model.2").unwrap().messages) + 10;
compact_session_loops(
&mut session,
"test.model.3",
&DefaultBlockCompaction,
&config,
small_budget,
None,
);
assert!(session
.get_loop("test.model.3")
.unwrap()
.compaction_block
.is_some());
assert!(session
.get_loop("test.model.2")
.unwrap()
.compaction_block
.is_some());
assert!(session
.get_loop("test.model.1")
.unwrap()
.compaction_block
.is_none());
}
#[test]
fn test_compaction_scope_token_budget_all_fit() {
let loop1 = make_loop_record("test.model.1", 2, None);
let loop2 = make_loop_record("test.model.2", 2, Some("test.model.1"));
let mut session = make_session(vec![loop1, loop2]);
let config = CompactionConfig {
compaction_scope: CompactionScope::TokenBudget,
..CompactionConfig::default()
};
compact_session_loops(
&mut session,
"test.model.2",
&DefaultBlockCompaction,
&config,
1_000_000,
None,
);
assert!(session
.get_loop("test.model.2")
.unwrap()
.compaction_block
.is_some());
assert!(session
.get_loop("test.model.1")
.unwrap()
.compaction_block
.is_some());
}
#[test]
fn test_build_context_token_budget_scope() {
let mut loop1 = make_loop_record("test.model.1", 5, None);
let mut loop2 = make_loop_record("test.model.2", 5, Some("test.model.1"));
let mut loop3 = make_loop_record("test.model.3", 5, Some("test.model.2"));
let config = CompactionConfig {
compaction_scope: CompactionScope::TokenBudget,
keep_first_turns: 1,
keep_recent_turns: 2,
max_summary_tokens: 2_000,
..CompactionConfig::default()
};
loop1.compaction_block = Some(DefaultBlockCompaction.compact(&loop1, &config, false));
loop2.compaction_block = Some(DefaultBlockCompaction.compact(&loop2, &config, false));
loop3.compaction_block = Some(DefaultBlockCompaction.compact(&loop3, &config, true));
let session = make_session(vec![loop1, loop2, loop3]);
let small_budget = 200;
let context = build_context_from_session(&session, "test.model.3", &config, small_budget, None);
let large_budget = 1_000_000;
let context_large =
build_context_from_session(&session, "test.model.3", &config, large_budget, None);
assert!(context_large.len() >= context.len());
}