use super::*;
#[test]
fn effective_origin_falls_back_to_id_when_unset() {
let s = Session::new("openrouter", "test/model", 0);
assert!(s.origin_id.is_none());
assert_eq!(s.effective_origin(), s.id.as_str());
}
#[test]
fn effective_origin_returns_origin_id_when_set() {
let mut s = Session::new("openrouter", "test/model", 0);
s.origin_id = Some(compact_str::CompactString::new("conv-root"));
assert_eq!(s.effective_origin(), "conv-root");
assert_ne!(s.effective_origin(), s.id.as_str());
}
#[test]
fn first_user_prompt_is_the_verbatim_first_user_message() {
let mut s = Session::new("openrouter", "test/model", 0);
s.add_message(MessageRole::System, "system preamble");
s.add_message(MessageRole::User, "the original ask");
s.add_message(MessageRole::Assistant, "working on it");
s.add_message(MessageRole::User, "a follow-up");
assert_eq!(s.first_user_prompt(), Some("the original ask"));
}
#[test]
fn first_user_prompt_is_none_without_a_user_message() {
let mut s = Session::new("openrouter", "test/model", 0);
s.add_message(MessageRole::System, "only system");
assert_eq!(s.first_user_prompt(), None);
}
#[test]
fn add_message_populates_id_and_timestamp() {
let mut s = Session::new("openrouter", "test/model", 0);
s.add_message(MessageRole::User, "hi");
let m = &s.messages[0];
assert!(!m.id.is_empty(), "id should be assigned");
assert_eq!(m.id.chars().count(), 36);
assert_eq!(m.id.matches('-').count(), 4);
assert!(m.timestamp > 1_700_000_000, "got {}", m.timestamp);
}
#[test]
fn each_message_gets_a_unique_id() {
let mut s = Session::new("p", "m", 0);
for i in 0..50 {
s.add_message(MessageRole::User, &format!("msg {i}"));
}
let mut ids: Vec<_> = s
.messages
.iter()
.map(|m| m.id.as_str().to_string())
.collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 50, "ids should be unique across 50 messages");
}
#[test]
fn tool_call_args_and_results_count_toward_estimate() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "hi");
let baseline = s.total_estimated_tokens;
let result = "x".repeat(8000);
let args = serde_json::json!({ "command": "ls -la /very/deep/path" });
let tc = ToolCallEntry {
id: "t1".to_string(),
name: "bash".to_string(),
args: args.clone(),
state: ToolCallState::Completed {
result: result.clone(),
},
};
s.add_message_with_tool_calls(MessageRole::Assistant, "I'll run that.", vec![tc]);
let delta = s.total_estimated_tokens - baseline;
assert!(
delta >= 1900,
"tool result must dominate the estimate: delta = {delta}",
);
assert!(delta < 3000, "delta = {delta} should be ~2050");
}
#[test]
fn failed_tool_call_counts_error_text() {
let mut s = Session::new("p", "m", 0);
let big_err = "y".repeat(4000);
let tc = ToolCallEntry {
id: "t1".to_string(),
name: "bash".to_string(),
args: serde_json::json!({}),
state: ToolCallState::Failed {
error: big_err.clone(),
},
};
s.add_message_with_tool_calls(MessageRole::Assistant, "", vec![tc]);
assert!(
s.total_estimated_tokens >= 900,
"failed-tool error must be counted: got {}",
s.total_estimated_tokens,
);
}
#[test]
fn legacy_session_json_loads_with_defaults() {
let legacy = r#"{
"id": "abc",
"name": "",
"messages": [{
"role": "user",
"content": "hi",
"estimated_tokens": 1
}],
"compactions": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"total_tokens": 0,
"total_cost": 0.0,
"total_estimated_tokens": 1,
"context_window": 0,
"model": "x",
"provider": "p",
"working_dir": ""
}"#;
let s: Session = serde_json::from_str(legacy).expect("legacy session should load");
assert_eq!(s.messages.len(), 1);
let m = &s.messages[0];
assert_eq!(m.id.chars().count(), 36);
assert_eq!(m.timestamp, 0);
assert_eq!(m.content, "hi");
}
#[test]
fn session_serde_roundtrip_preserves_id_and_timestamp() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "hello");
let original_id = s.messages[0].id.clone();
let original_ts = s.messages[0].timestamp;
let serialized = serde_json::to_string(&s).unwrap();
let restored: Session = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored.messages[0].id, original_id);
assert_eq!(restored.messages[0].timestamp, original_ts);
}
#[test]
fn compress_summary_message_has_id_and_timestamp() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "earlier");
s.add_message(MessageRole::Assistant, "reply");
s.compress("compacted context".to_string(), 2, 10);
let m = &s.messages[0];
assert!(matches!(m.role, MessageRole::System));
assert_eq!(m.id.chars().count(), 36);
assert!(m.timestamp > 0);
}
#[test]
fn add_message_extends_tree_chain() {
let mut s = Session::new("p", "m", 0);
assert!(s.tree.entries.is_empty());
assert!(s.tree.leaf_id.is_none());
s.add_message(MessageRole::User, "first");
let first_id = s.messages[0].id.clone();
assert_eq!(s.tree.entries.len(), 1);
assert_eq!(s.tree.leaf_id.as_ref(), Some(&first_id));
assert_eq!(s.tree.entries[&first_id].parent, None);
s.add_message(MessageRole::Assistant, "second");
let second_id = s.messages[1].id.clone();
assert_eq!(s.tree.entries.len(), 2);
assert_eq!(s.tree.leaf_id.as_ref(), Some(&second_id));
assert_eq!(s.tree.entries[&second_id].parent.as_ref(), Some(&first_id));
}
#[test]
fn legacy_session_initializes_tree_from_messages() {
let legacy = r#"{
"id": "abc",
"name": "",
"messages": [
{"role": "user", "content": "a", "estimated_tokens": 1,
"id": "msg-1", "timestamp": 100},
{"role": "assistant", "content": "b", "estimated_tokens": 1,
"id": "msg-2", "timestamp": 101}
],
"compactions": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"total_tokens": 0,
"total_cost": 0.0,
"total_estimated_tokens": 2,
"context_window": 0,
"model": "x",
"provider": "p",
"working_dir": ""
}"#;
let mut s: Session = serde_json::from_str(legacy).unwrap();
assert!(s.tree.entries.is_empty(), "tree should default empty");
s.ensure_tree_initialized();
assert_eq!(s.tree.entries.len(), 2);
assert_eq!(s.tree.leaf_id.as_deref(), Some("msg-2"));
assert_eq!(s.tree.entries["msg-1"].parent, None);
assert_eq!(s.tree.entries["msg-2"].parent.as_deref(), Some("msg-1"));
}
#[test]
fn session_serde_roundtrip_preserves_tree() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "hi");
s.add_message(MessageRole::Assistant, "yo");
let orig_leaf = s.tree.leaf_id.clone();
let serialized = serde_json::to_string(&s).unwrap();
let restored: Session = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored.tree.entries.len(), 2);
assert_eq!(restored.tree.leaf_id, orig_leaf);
}
#[test]
fn compress_rebuilds_tree_with_summary_root() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "ancient-1");
s.add_message(MessageRole::Assistant, "ancient-2");
s.add_message(MessageRole::User, "kept");
s.compress("compacted".to_string(), 2, 10);
assert_eq!(s.tree.entries.len(), 2);
let summary_id = s.messages[0].id.clone();
let kept_id = s.messages[1].id.clone();
assert_eq!(s.tree.entries[&summary_id].parent, None);
assert_eq!(s.tree.entries[&kept_id].parent.as_ref(), Some(&summary_id));
assert_eq!(s.tree.leaf_id.as_ref(), Some(&kept_id));
}
#[test]
fn ensure_tree_initialized_is_idempotent() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "msg");
let snapshot = s.tree.entries.len();
let snapshot_leaf = s.tree.leaf_id.clone();
s.ensure_tree_initialized();
s.ensure_tree_initialized();
s.ensure_tree_initialized();
assert_eq!(s.tree.entries.len(), snapshot);
assert_eq!(s.tree.leaf_id, snapshot_leaf);
}
#[test]
fn add_message_populates_message_store() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "hello");
s.add_message(MessageRole::Assistant, "world");
assert_eq!(s.message_store.len(), 2);
let first_id = s.messages[0].id.clone();
assert_eq!(s.message_store[&first_id].content, "hello");
}
#[test]
fn pop_last_message_removes_from_all_three() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "a");
s.add_message(MessageRole::Assistant, "b");
let popped_id = s.messages[1].id.clone();
let popped = s.pop_last_message().unwrap();
assert_eq!(popped.content, "b");
assert_eq!(s.messages.len(), 1);
assert!(!s.message_store.contains_key(&popped_id));
assert!(!s.tree.entries.contains_key(&popped_id));
assert_eq!(s.tree.leaf_id.as_ref(), Some(&s.messages[0].id));
}
#[test]
fn fork_at_preserves_original_branch_content() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "ask-1");
s.add_message(MessageRole::Assistant, "ans-1");
let original_leaf = s.tree.leaf_id.clone().unwrap();
let assistant_id = s.messages[1].id.clone();
let user_id = s.messages[0].id.clone();
let original_msg = s.fork_at(&assistant_id).unwrap();
assert_eq!(original_msg.content, "ans-1");
assert_eq!(s.messages.len(), 1);
assert_eq!(s.tree.leaf_id, Some(user_id.clone()));
assert!(s.tree.entries.contains_key(&assistant_id));
assert!(s.message_store.contains_key(&assistant_id));
s.add_message(MessageRole::Assistant, "ans-2-alternate");
let new_leaf = s.tree.leaf_id.clone().unwrap();
assert_ne!(new_leaf, original_leaf);
let children: Vec<_> = s
.tree
.entries
.values()
.filter(|n| n.parent.as_ref() == Some(&user_id))
.collect();
assert_eq!(children.len(), 2);
}
#[test]
fn switch_to_leaf_rebuilds_messages_and_tokens() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
s.add_message(MessageRole::Assistant, "a-original");
let original_leaf = s.tree.leaf_id.clone().unwrap();
let user_id = s.messages[0].id.clone();
s.fork_at(&s.messages[1].id.clone()).unwrap();
s.add_message(MessageRole::Assistant, "a-alternate-and-much-longer");
let alt_leaf = s.tree.leaf_id.clone().unwrap();
let alt_tokens = s.total_estimated_tokens;
s.switch_to_leaf(&original_leaf).unwrap();
assert_eq!(s.messages.len(), 2);
assert_eq!(s.messages[1].content, "a-original");
assert_eq!(s.tree.leaf_id, Some(original_leaf.clone()));
let original_tokens = s.total_estimated_tokens;
s.switch_to_leaf(&alt_leaf).unwrap();
assert_eq!(s.messages.len(), 2);
assert_eq!(s.messages[1].content, "a-alternate-and-much-longer");
assert_eq!(s.total_estimated_tokens, alt_tokens);
assert_ne!(original_tokens, alt_tokens);
assert_eq!(s.messages[0].id, user_id);
}
#[test]
fn switch_to_unknown_leaf_returns_err() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "msg");
let original_leaf = s.tree.leaf_id.clone();
let bogus = CompactString::new("nonexistent-id");
let err = s.switch_to_leaf(&bogus).unwrap_err();
assert!(err.contains("nonexistent-id"), "got: {err}");
assert_eq!(s.tree.leaf_id, original_leaf);
}
#[test]
fn clone_at_is_switch_to_leaf() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
s.add_message(MessageRole::Assistant, "a-original");
let original = s.tree.leaf_id.clone().unwrap();
s.fork_at(&s.messages[1].id.clone()).unwrap();
s.add_message(MessageRole::Assistant, "a-alternate");
s.clone_at(&original).unwrap();
assert_eq!(s.tree.leaf_id, Some(original));
assert_eq!(s.messages[1].content, "a-original");
}
#[test]
fn set_label_attaches_to_node() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "milestone");
let id = s.messages[0].id.clone();
s.set_label(&id, Some("checkpoint-1".to_string())).unwrap();
assert_eq!(s.tree.entries[&id].label.as_deref(), Some("checkpoint-1"));
s.set_label(&id, None).unwrap();
assert_eq!(s.tree.entries[&id].label, None);
}
#[test]
fn ensure_message_store_initialized_backfills_from_messages() {
let legacy = r#"{
"id": "abc",
"name": "",
"messages": [
{"role": "user", "content": "a", "estimated_tokens": 1,
"id": "msg-1", "timestamp": 100},
{"role": "assistant", "content": "b", "estimated_tokens": 1,
"id": "msg-2", "timestamp": 101}
],
"compactions": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"total_tokens": 0,
"total_cost": 0.0,
"total_estimated_tokens": 2,
"context_window": 0,
"model": "x",
"provider": "p",
"working_dir": ""
}"#;
let mut s: Session = serde_json::from_str(legacy).unwrap();
assert!(s.message_store.is_empty());
s.ensure_message_store_initialized();
assert_eq!(s.message_store.len(), 2);
assert_eq!(
s.message_store
.get(&CompactString::new("msg-1"))
.map(|m| m.content.as_str()),
Some("a")
);
}
#[test]
fn reset_to_new_clears_content_but_keeps_runtime_metadata() {
let mut s = Session::new("openai", "gpt-4", 200_000);
s.add_message(MessageRole::User, "old prompt");
s.add_message(MessageRole::Assistant, "old reply");
s.append_plugin_entry("bookmark", "stale", true);
s.compactions.push(Compaction {
summary: CompactString::from("dropped"),
first_kept_index: 0,
summarized_count: 1,
token_savings: 0,
created_at: CompactString::from("2024-01-01"),
});
let original_id = s.id.clone();
s.reset_to_new(None);
assert_ne!(s.id, original_id, "id must change");
assert!(s.messages.is_empty());
assert!(s.message_store.is_empty());
assert!(s.tree.entries.is_empty());
assert!(s.tree.leaf_id.is_none());
assert!(s.compactions.is_empty());
assert!(s.extra_entries.is_empty());
assert_eq!(s.next_entry_seq, 0);
assert_eq!(s.total_estimated_tokens, 0);
assert_eq!(s.model, "gpt-4");
assert_eq!(s.provider, "openai");
assert_eq!(s.context_window, 200_000);
assert_eq!(s.name, "");
}
#[test]
fn ensure_back_compat_reseeds_next_entry_seq_from_stale_value() {
let mut s = Session::new("openai", "gpt-4", 200_000);
s.extra_entries.push(PluginEntry {
seq: 0,
timestamp: 0,
display: true,
custom_type: "bookmark".to_string(),
data: "a".to_string(),
});
s.extra_entries.push(PluginEntry {
seq: 1,
timestamp: 0,
display: true,
custom_type: "bookmark".to_string(),
data: "b".to_string(),
});
s.extra_entries.push(PluginEntry {
seq: 2,
timestamp: 0,
display: true,
custom_type: "bookmark".to_string(),
data: "c".to_string(),
});
s.next_entry_seq = 0;
s.ensure_back_compat_initialized();
assert!(
s.next_entry_seq >= 3,
"next_entry_seq must skip past existing seqs; got {}",
s.next_entry_seq,
);
let added_seq = s.append_plugin_entry("bookmark", "d", true).seq;
let seqs: std::collections::HashSet<u64> = s.extra_entries.iter().map(|e| e.seq).collect();
assert_eq!(seqs.len(), 4, "all seqs must be unique; got {seqs:?}");
assert!(added_seq >= 3, "new entry's seq must skip past existing");
}
#[test]
fn reset_to_new_clears_permission_allowlist() {
let mut s = Session::new("openai", "gpt-4", 200_000);
s.permission_allowlist.push(PermissionAllowEntry {
tool: "bash".to_string(),
pattern: "rm -rf /tmp/foo".to_string(),
});
assert!(!s.permission_allowlist.is_empty());
s.reset_to_new(None);
assert!(
s.permission_allowlist.is_empty(),
"allowlist must reset on new session; got {:?}",
s.permission_allowlist,
);
}
#[test]
fn pop_last_message_refuses_to_pop_summary_at_index_zero() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
s.add_message(MessageRole::Assistant, "a1");
s.add_message(MessageRole::User, "u2");
s.add_message(MessageRole::Assistant, "a2");
s.compress("summary".to_string(), 4, 100);
assert_eq!(s.messages.len(), 1, "post-compress shape");
assert_eq!(s.messages[0].role, MessageRole::System);
let popped = s.pop_last_message();
assert!(
popped.is_none(),
"pop must refuse the summary; got {:?}",
popped,
);
assert_eq!(s.messages.len(), 1, "summary must remain");
assert_eq!(s.messages[0].role, MessageRole::System);
}
#[test]
fn pop_drains_recent_but_keeps_summary() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
s.add_message(MessageRole::Assistant, "a1");
s.compress("summary".to_string(), 2, 50);
s.add_message(MessageRole::User, "u2");
s.add_message(MessageRole::Assistant, "a2");
assert_eq!(s.messages.len(), 3);
assert!(s.pop_last_message().is_some());
assert!(s.pop_last_message().is_some());
assert_eq!(s.messages.len(), 1);
assert_eq!(s.messages[0].role, MessageRole::System);
assert!(s.pop_last_message().is_none());
assert_eq!(s.messages.len(), 1);
}
#[test]
fn compress_records_branch_summary_for_pruned_siblings() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
let u1_id = s.messages.last().unwrap().id.clone();
s.add_message(MessageRole::Assistant, "a1");
s.add_message(MessageRole::User, "u2 keep");
s.add_message(MessageRole::Assistant, "a2 keep");
let sib1_id = CompactString::new("sib_alpha");
let sib2_id = CompactString::new("sib_beta");
s.tree.entries.insert(
sib1_id.clone(),
TreeNode {
id: sib1_id.clone(),
parent: Some(u1_id.clone()),
timestamp: 100,
label: Some("explore-alt".to_string()),
},
);
s.tree.entries.insert(
sib2_id.clone(),
TreeNode {
id: sib2_id.clone(),
parent: Some(sib1_id.clone()),
timestamp: 200,
label: None,
},
);
s.message_store.insert(
sib1_id.clone(),
SessionMessage {
role: MessageRole::Assistant,
content: CompactString::from(
"let me try a different approach: investigate the foo module first",
),
estimated_tokens: 10,
id: sib1_id.clone(),
timestamp: 100,
tool_calls: Vec::new(),
},
);
s.message_store.insert(
sib2_id.clone(),
SessionMessage {
role: MessageRole::User,
content: CompactString::from("continue with that"),
estimated_tokens: 3,
id: sib2_id.clone(),
timestamp: 200,
tool_calls: Vec::new(),
},
);
let baseline = s.branch_summaries.len();
let pruned = s.compress_reporting("summary".to_string(), 2, 10);
assert_eq!(pruned, 2, "expected 2 sibling nodes pruned");
assert_eq!(
s.branch_summaries.len(),
baseline + 1,
"expected 1 branch summary; got {:?}",
s.branch_summaries,
);
let summary = s.branch_summaries.last().unwrap();
assert_eq!(summary.parent_id, u1_id);
assert_eq!(summary.message_count, 2);
assert!(
summary.preview.contains("explore-alt") || summary.preview.contains("different approach"),
"preview missing branch info: {:?}",
summary.preview,
);
}
#[test]
fn reset_to_new_clears_branch_summaries() {
let mut s = Session::new("p", "m", 0);
s.branch_summaries.push(BranchSummary {
root_id: CompactString::from("x"),
parent_id: CompactString::from("y"),
message_count: 3,
preview: "lingering branch".to_string(),
created_at: "2026-01-01".to_string(),
});
s.reset_to_new(None);
assert!(s.branch_summaries.is_empty());
}
#[test]
fn session_message_tool_calls_default_when_field_missing() {
let json = r#"{
"role": "assistant",
"content": "Done.",
"estimated_tokens": 5
}"#;
let msg: SessionMessage = serde_json::from_str(json).unwrap();
assert!(
msg.tool_calls.is_empty(),
"missing field must default to []"
);
}
#[test]
fn session_message_tool_calls_roundtrip() {
let mut s = Session::new("p", "m", 0);
let calls = vec![
ToolCallEntry {
id: "tc_1".to_string(),
name: "bash".to_string(),
args: serde_json::json!({"cmd": "ls"}),
state: ToolCallState::Completed {
result: "file1\nfile2".to_string(),
},
},
ToolCallEntry {
id: "tc_2".to_string(),
name: "read".to_string(),
args: serde_json::json!({"path": "/tmp/x"}),
state: ToolCallState::Interrupted,
},
];
s.add_message_with_tool_calls(MessageRole::Assistant, "Let me check.", calls.clone());
let blob = serde_json::to_string(&s).unwrap();
let s2: Session = serde_json::from_str(&blob).unwrap();
let last = s2.messages.last().unwrap();
assert_eq!(last.tool_calls.len(), 2);
assert_eq!(last.tool_calls[0].id, "tc_1");
assert!(matches!(
last.tool_calls[0].state,
ToolCallState::Completed { .. },
));
assert!(matches!(
last.tool_calls[1].state,
ToolCallState::Interrupted,
));
}
#[test]
fn convert_history_emits_tool_use_and_tool_result_blocks() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "list files");
s.add_message_with_tool_calls(
MessageRole::Assistant,
"Here:",
vec![ToolCallEntry {
id: "tc_42".to_string(),
name: "bash".to_string(),
args: serde_json::json!({"cmd": "ls"}),
state: ToolCallState::Completed {
result: "a\nb".to_string(),
},
}],
);
let history = crate::agent::runner::convert_history(&s);
assert_eq!(history.len(), 3, "history shape: {:#?}", history);
match &history[2] {
rig::completion::Message::User { content } => {
let s = format!("{:?}", content);
assert!(s.contains("tc_42"), "tool_result missing call id: {s}");
assert!(
s.contains("a\\nb") || s.contains("a\nb"),
"tool_result missing output: {s}",
);
}
other => panic!("expected User tool_result message; got {other:?}"),
}
match &history[1] {
rig::completion::Message::Assistant { content, .. } => {
let s = format!("{:?}", content);
assert!(s.contains("tc_42"), "tool_use missing id: {s}");
assert!(s.contains("\"bash\""), "tool_use missing name: {s}");
}
other => panic!("expected Assistant message; got {other:?}"),
}
}
#[test]
fn convert_history_pairs_interrupted_tool_calls_with_error_marker() {
let mut s = Session::new("p", "m", 0);
s.add_message_with_tool_calls(
MessageRole::Assistant,
"About to bash...",
vec![ToolCallEntry {
id: "tc_99".to_string(),
name: "bash".to_string(),
args: serde_json::json!({"cmd": "sleep 9999"}),
state: ToolCallState::Interrupted,
}],
);
let history = crate::agent::runner::convert_history(&s);
assert_eq!(history.len(), 2);
let last_str = format!("{:?}", &history[1]);
assert!(
last_str.contains("tc_99"),
"interrupted result must reference call id: {last_str}",
);
assert!(
last_str.contains("interrupted") || last_str.contains("Interrupted"),
"interrupted result must say so: {last_str}",
);
}
#[test]
fn compress_prunes_sibling_branches_rooted_at_dropped_messages() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
let u1_id = s.messages.last().unwrap().id.clone();
s.add_message(MessageRole::Assistant, "a1");
s.add_message(MessageRole::User, "u2");
s.add_message(MessageRole::Assistant, "a2");
let sib1_id = CompactString::new("sib1");
let sib2_id = CompactString::new("sib2");
s.tree.entries.insert(
sib1_id.clone(),
TreeNode {
id: sib1_id.clone(),
parent: Some(u1_id.clone()),
timestamp: 0,
label: None,
},
);
s.tree.entries.insert(
sib2_id.clone(),
TreeNode {
id: sib2_id.clone(),
parent: Some(sib1_id.clone()),
timestamp: 0,
label: None,
},
);
s.message_store.insert(
sib1_id.clone(),
SessionMessage {
role: MessageRole::Assistant,
content: CompactString::from("sib1"),
estimated_tokens: 1,
id: sib1_id.clone(),
timestamp: 0,
tool_calls: Vec::new(),
},
);
s.message_store.insert(
sib2_id.clone(),
SessionMessage {
role: MessageRole::Assistant,
content: CompactString::from("sib2"),
estimated_tokens: 1,
id: sib2_id.clone(),
timestamp: 0,
tool_calls: Vec::new(),
},
);
let pruned = s.compress_reporting("summary".to_string(), 2, 10);
assert!(!s.tree.entries.contains_key(&u1_id), "u1 still in tree");
assert!(!s.tree.entries.contains_key(&sib1_id), "sib1 still in tree");
assert!(!s.tree.entries.contains_key(&sib2_id), "sib2 still in tree");
assert!(
!s.message_store.contains_key(&sib1_id),
"sib1 still in store",
);
assert!(
!s.message_store.contains_key(&sib2_id),
"sib2 still in store",
);
assert_eq!(pruned, 2, "expected 2 sibling nodes pruned, got {pruned}",);
}
#[test]
fn compress_reports_zero_pruned_when_no_siblings() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "u1");
s.add_message(MessageRole::Assistant, "a1");
s.add_message(MessageRole::User, "u2");
s.add_message(MessageRole::Assistant, "a2");
let pruned = s.compress_reporting("summary".to_string(), 2, 10);
assert_eq!(pruned, 0);
}
#[test]
fn reset_to_new_records_parent_lineage() {
let mut s = Session::new("p", "m", 0);
s.add_message(MessageRole::User, "x");
s.reset_to_new(Some("00000000-prev"));
assert_eq!(s.name, "parent:00000000-prev");
}
#[test]
fn cache_hit_ratio_none_before_any_usage() {
let s = Session::new("p", "m", 0);
assert_eq!(s.cache_hit_ratio(), None);
}
#[test]
fn cache_hit_ratio_accumulates_across_turns() {
let mut s = Session::new("p", "m", 0);
s.record_token_usage(1000, 800, 0);
s.record_token_usage(500, 100, 0);
assert_eq!(s.cumulative_input_tokens, 1500);
assert_eq!(s.cumulative_cached_input_tokens, 900);
let ratio = s.cache_hit_ratio().expect("usage recorded");
assert!((ratio - 0.6).abs() < 1e-9, "ratio was {ratio}");
}
#[test]
fn cache_creation_tracked_separately_from_ratio() {
let mut s = Session::new("p", "m", 0);
s.record_token_usage(200, 0, 200);
assert_eq!(s.cumulative_cache_creation_tokens, 200);
assert_eq!(s.cache_hit_ratio(), Some(0.0));
}
#[test]
fn cache_fields_default_on_legacy_deserialize() {
let s = Session::new("p", "m", 0);
let mut v = serde_json::to_value(&s).expect("serialize");
let obj = v.as_object_mut().expect("object");
obj.remove("cumulative_input_tokens");
obj.remove("cumulative_cached_input_tokens");
obj.remove("cumulative_cache_creation_tokens");
let restored: Session = serde_json::from_value(v).expect("deserialize legacy");
assert_eq!(restored.cumulative_input_tokens, 0);
assert_eq!(restored.cache_hit_ratio(), None);
}