mod assembly;
mod summarization;
use zeph_memory::TokenCounter;
use super::{Agent, Channel, Message};
pub(super) fn chunk_messages(
messages: &[Message],
budget: usize,
oversized: usize,
tc: &TokenCounter,
) -> Vec<Vec<Message>> {
let mut chunks: Vec<Vec<Message>> = Vec::new();
let mut current: Vec<Message> = Vec::new();
let mut current_tokens = 0usize;
for msg in messages {
let msg_tokens = tc.count_message_tokens(msg);
if msg_tokens >= oversized {
if !current.is_empty() {
chunks.push(std::mem::take(&mut current));
current_tokens = 0;
}
chunks.push(vec![msg.clone()]);
} else if current_tokens + msg_tokens > budget && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
current_tokens = 0;
current.push(msg.clone());
current_tokens += msg_tokens;
} else {
current.push(msg.clone());
current_tokens += msg_tokens;
}
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
chunks.push(Vec::new());
}
chunks
}
pub(super) use crate::text::truncate_to_chars as truncate_chars;
pub(super) fn cap_summary(s: String, max_chars: usize) -> String {
match s.char_indices().nth(max_chars) {
Some((byte_idx, _)) => {
tracing::warn!(
original_chars = s.chars().count(),
cap = max_chars,
"LLM summary exceeded cap, truncating"
);
format!("{}…", &s[..byte_idx])
}
None => s,
}
}
pub(super) enum ContextSlot {
Summaries(Option<Message>),
CrossSession(Option<Message>),
SemanticRecall(Option<Message>),
DocumentRag(Option<Message>),
Corrections(Option<Message>),
CodeContext(Option<String>),
GraphFacts(Option<Message>),
}
impl<C: Channel> Agent<C> {
pub(super) fn compaction_tier(&self) -> super::context_manager::CompactionTier {
self.context_manager
.compaction_tier(self.providers.cached_prompt_tokens)
}
}
#[cfg(test)]
use super::{CORRECTIONS_PREFIX, RECALL_PREFIX, SUMMARY_PREFIX};
#[cfg(test)]
use crate::context::ContextBudget;
#[cfg(test)]
use zeph_llm::provider::MessagePart;
#[cfg(test)]
use zeph_skills::ScoredMatch;
#[cfg(test)]
use zeph_skills::loader::SkillMeta;
#[cfg(test)]
mod tests {
use super::super::MemoryState;
#[allow(clippy::wildcard_imports)]
use super::*;
#[allow(clippy::wildcard_imports)]
use crate::agent::agent_tests::*;
use crate::agent::context_manager::CompactionTier;
#[test]
fn chunk_messages_empty_input_returns_single_empty_chunk() {
let tc = zeph_memory::TokenCounter::new();
let messages: &[Message] = &[];
let chunks = chunk_messages(messages, 4096, 2048, &tc);
assert_eq!(chunks.len(), 1);
assert!(chunks[0].is_empty());
}
#[test]
fn chunk_messages_single_oversized_message_gets_own_chunk() {
let tc = zeph_memory::TokenCounter::new();
let oversized_content = "x".repeat(2048 * 4 + 1); let messages = vec![Message {
role: Role::User,
content: oversized_content.clone(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let chunks = chunk_messages(&messages, 4096, 2048, &tc);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0][0].content, oversized_content);
}
#[test]
fn chunk_messages_splits_at_budget_boundary() {
let tc = zeph_memory::TokenCounter::new();
let half = "w".repeat(1000 * 4); let messages = vec![
Message {
role: Role::User,
content: half.clone(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: half.clone(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: half.clone(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let chunks = chunk_messages(&messages, 2000, 4096, &tc);
assert!(chunks.len() >= 2, "expected split into multiple chunks");
}
#[test]
fn skill_prompt_mode_auto_selects_compact_when_budget_below_8192() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(4096, 0.20, 0.80, 4, 0);
let effective_mode = match crate::config::SkillPromptMode::Auto {
crate::config::SkillPromptMode::Auto => {
if let Some(ref budget) = agent.context_manager.budget
&& budget.max_tokens() < 8192
{
crate::config::SkillPromptMode::Compact
} else {
crate::config::SkillPromptMode::Full
}
}
other => other,
};
assert_eq!(effective_mode, crate::config::SkillPromptMode::Compact);
}
#[test]
fn skill_prompt_mode_auto_selects_full_when_budget_above_8192() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(16384, 0.20, 0.80, 4, 0);
let effective_mode = match crate::config::SkillPromptMode::Auto {
crate::config::SkillPromptMode::Auto => {
if let Some(ref budget) = agent.context_manager.budget
&& budget.max_tokens() < 8192
{
crate::config::SkillPromptMode::Compact
} else {
crate::config::SkillPromptMode::Full
}
}
other => other,
};
assert_eq!(effective_mode, crate::config::SkillPromptMode::Full);
}
#[test]
fn skill_prompt_mode_compact_forced_regardless_of_budget() {
let effective_mode = match crate::config::SkillPromptMode::Compact {
crate::config::SkillPromptMode::Auto => {
crate::config::SkillPromptMode::Full }
other => other,
};
assert_eq!(effective_mode, crate::config::SkillPromptMode::Compact);
}
#[test]
fn compaction_tier_disabled_without_budget() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
for i in 0..20 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} with some content to add tokens"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
assert_eq!(agent.compaction_tier(), CompactionTier::None);
}
#[test]
fn compaction_tier_none_below_soft() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.90, 4, 0);
assert_eq!(agent.compaction_tier(), CompactionTier::None);
}
#[test]
fn compaction_tier_hard_above_threshold() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 4, 0)
.with_soft_compaction_threshold(0.50);
for i in 0..20 {
agent.messages.push(Message {
role: Role::User,
content: format!("message number {i} with enough content to push over budget"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
assert_eq!(agent.compaction_tier(), CompactionTier::Hard);
}
#[tokio::test]
async fn compact_context_preserves_system_and_tail() {
let provider = mock_provider(vec!["compacted summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0);
let system_content = agent.messages[0].content.clone();
for i in 0..8 {
agent.messages.push(Message {
role: if i % 2 == 0 {
Role::User
} else {
Role::Assistant
},
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.compact_context().await.unwrap();
assert_eq!(agent.messages[0].role, Role::System);
assert_eq!(agent.messages[0].content, system_content);
assert_eq!(agent.messages[1].role, Role::System);
assert!(agent.messages[1].content.contains("[conversation summary"));
let tail = &agent.messages[2..];
assert_eq!(tail.len(), 2);
assert_eq!(tail[0].content, "message 6");
assert_eq!(tail[1].content, "message 7");
}
#[tokio::test]
async fn compact_context_too_few_messages() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 4, 0);
agent.messages.push(Message {
role: Role::User,
content: "msg1".to_string(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.messages.push(Message {
role: Role::Assistant,
content: "msg2".to_string(),
parts: vec![],
metadata: MessageMetadata::default(),
});
let len_before = agent.messages.len();
agent.compact_context().await.unwrap();
assert_eq!(agent.messages.len(), len_before);
}
#[test]
fn with_context_budget_zero_disables() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(0, 0.20, 0.75, 4, 0);
assert!(agent.context_manager.budget.is_none());
}
#[test]
fn with_context_budget_nonzero_enables() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(4096, 0.20, 0.80, 6, 0);
assert!(agent.context_manager.budget.is_some());
assert_eq!(
agent.context_manager.budget.as_ref().unwrap().max_tokens(),
4096
);
assert!((agent.context_manager.hard_compaction_threshold - 0.80).abs() < f32::EPSILON);
assert_eq!(agent.context_manager.compaction_preserve_tail, 6);
}
#[tokio::test]
async fn compact_context_increments_metric() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_metrics(tx);
for i in 0..8 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.compact_context().await.unwrap();
assert_eq!(rx.borrow().context_compactions, 1);
}
#[tokio::test]
async fn test_prepare_context_no_budget_is_noop() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let msg_count = agent.messages.len();
agent.prepare_context("test query").await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_correction_messages_removed_between_turns() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message {
role: Role::System,
content: format!("{CORRECTIONS_PREFIX}old correction data"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
assert_eq!(agent.messages.len(), 2);
agent.remove_correction_messages();
assert_eq!(agent.messages.len(), 1);
assert!(!agent.messages[0].content.starts_with(CORRECTIONS_PREFIX));
}
#[tokio::test]
async fn test_remove_correction_messages_preserves_non_correction_system() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message {
role: Role::System,
content: "regular system message".to_string(),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
agent.messages.insert(
2,
Message {
role: Role::System,
content: format!("{CORRECTIONS_PREFIX}correction data"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
assert_eq!(agent.messages.len(), 3);
agent.remove_correction_messages();
assert_eq!(agent.messages.len(), 2);
assert!(
agent
.messages
.iter()
.any(|m| m.content == "regular system message")
);
assert!(
!agent
.messages
.iter()
.any(|m| m.content.starts_with(CORRECTIONS_PREFIX))
);
}
#[tokio::test]
async fn test_recall_injection_removed_between_turns() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message {
role: Role::System,
content: format!("{RECALL_PREFIX}old recall data"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
assert_eq!(agent.messages.len(), 2);
agent.remove_recall_messages();
assert_eq!(agent.messages.len(), 1);
assert!(!agent.messages[0].content.starts_with(RECALL_PREFIX));
}
#[tokio::test]
async fn test_recall_without_qdrant_returns_empty() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let msg_count = agent.messages.len();
agent.inject_semantic_recall("test", 1000).await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_trim_messages_preserves_system() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
assert_eq!(agent.messages.len(), 11);
agent.trim_messages_to_budget(5);
assert_eq!(agent.messages[0].role, Role::System);
assert!(agent.messages.len() < 11);
}
#[tokio::test]
async fn test_trim_messages_keeps_recent() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("msg {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.trim_messages_to_budget(5);
let last = agent.messages.last().unwrap();
assert_eq!(last.content, "msg 9");
}
#[tokio::test]
async fn test_trim_zero_budget_is_noop() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
for i in 0..5 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let msg_count = agent.messages.len();
agent.trim_messages_to_budget(0);
assert_eq!(agent.messages.len(), msg_count);
}
async fn create_memory_with_summaries(
provider: zeph_llm::any::AnyProvider,
summaries: &[&str],
) -> (SemanticMemory, zeph_memory::ConversationId) {
let memory = SemanticMemory::new(":memory:", "http://127.0.0.1:1", provider, "test")
.await
.unwrap();
let cid = memory.sqlite().create_conversation().await.unwrap();
for content in summaries {
let m1 = memory
.sqlite()
.save_message(cid, "user", "q")
.await
.unwrap();
let m2 = memory
.sqlite()
.save_message(cid, "assistant", "a")
.await
.unwrap();
memory
.sqlite()
.save_summary(
cid,
content,
Some(m1),
Some(m2),
i64::try_from(zeph_memory::TokenCounter::new().count_tokens(content)).unwrap(),
)
.await
.unwrap();
}
(memory, cid)
}
#[tokio::test]
async fn test_inject_summaries_no_memory_noop() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let msg_count = agent.messages.len();
agent.inject_summaries(1000).await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_inject_summaries_zero_budget_noop() {
let provider = mock_provider(vec![]);
let (memory, cid) = create_memory_with_summaries(provider.clone(), &["summary text"]).await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
let msg_count = agent.messages.len();
agent.inject_summaries(0).await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_inject_summaries_empty_summaries_noop() {
let provider = mock_provider(vec![]);
let (memory, cid) = create_memory_with_summaries(provider.clone(), &[]).await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
let msg_count = agent.messages.len();
agent.inject_summaries(1000).await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_inject_summaries_inserts_at_position_1() {
let provider = mock_provider(vec![]);
let (memory, cid) =
create_memory_with_summaries(provider.clone(), &["User asked about Rust ownership"])
.await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
agent.messages.push(Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.inject_summaries(1000).await.unwrap();
assert_eq!(agent.messages[0].role, Role::System);
assert!(agent.messages[1].content.starts_with(SUMMARY_PREFIX));
assert_eq!(agent.messages[1].role, Role::System);
assert!(
agent.messages[1]
.content
.contains("User asked about Rust ownership")
);
assert_eq!(agent.messages[2].content, "hello");
}
#[tokio::test]
async fn test_inject_summaries_removes_old_before_inject() {
let provider = mock_provider(vec![]);
let (memory, cid) =
create_memory_with_summaries(provider.clone(), &["new summary data"]).await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
agent.messages.insert(
1,
Message {
role: Role::System,
content: format!("{SUMMARY_PREFIX}old summary data"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
agent.messages.push(Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
assert_eq!(agent.messages.len(), 3);
agent.inject_summaries(1000).await.unwrap();
let summary_msgs: Vec<_> = agent
.messages
.iter()
.filter(|m| m.content.starts_with(SUMMARY_PREFIX))
.collect();
assert_eq!(summary_msgs.len(), 1);
assert!(summary_msgs[0].content.contains("new summary data"));
assert!(!summary_msgs[0].content.contains("old summary data"));
}
#[tokio::test]
async fn test_inject_summaries_respects_token_budget() {
let provider = mock_provider(vec![]);
let (memory, cid) = create_memory_with_summaries(
provider.clone(),
&[
"short",
"this is a much longer summary that should consume more tokens",
],
)
.await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
agent.messages.push(Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
let tc = zeph_memory::TokenCounter::new();
let prefix_cost = tc.count_tokens(SUMMARY_PREFIX);
agent.inject_summaries(prefix_cost + 10).await.unwrap();
let summary_msg = agent
.messages
.iter()
.find(|m| m.content.starts_with(SUMMARY_PREFIX));
if let Some(msg) = summary_msg {
let token_count = tc.count_tokens(&msg.content);
assert!(token_count <= prefix_cost + 10);
}
}
#[tokio::test]
async fn test_remove_summary_messages_preserves_other_system() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message {
role: Role::System,
content: format!("{SUMMARY_PREFIX}old summary"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
agent.messages.insert(
2,
Message {
role: Role::System,
content: format!("{RECALL_PREFIX}recall data"),
parts: vec![],
metadata: MessageMetadata::default(),
},
);
assert_eq!(agent.messages.len(), 3);
agent.remove_summary_messages();
assert_eq!(agent.messages.len(), 2);
assert!(agent.messages[1].content.starts_with(RECALL_PREFIX));
}
#[test]
fn test_prune_frees_tokens() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_metrics(tx);
let big_body = "x".repeat(500);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body: big_body,
compacted_at: None,
}],
));
let freed = agent.prune_tool_outputs(10);
assert!(freed > 0);
assert_eq!(rx.borrow().tool_output_prunes, 1);
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = &agent.messages[1].parts[0]
{
assert!(compacted_at.is_some());
assert!(body.is_empty(), "body should be cleared after prune");
} else {
panic!("expected ToolOutput");
}
}
#[test]
fn test_prune_respects_protection_zone() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(10000, 0.20, 0.75, 4, 999_999);
let big_body = "x".repeat(500);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body: big_body,
compacted_at: None,
}],
));
let freed = agent.prune_tool_outputs(10);
assert_eq!(freed, 0);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.messages[1].parts[0] {
assert!(compacted_at.is_none());
} else {
panic!("expected ToolOutput");
}
}
#[test]
fn prune_tool_outputs_preserves_overflow_reference() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, _rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_metrics(tx);
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let body = format!(
"truncated output\n[full output stored \u{2014} ID: {uuid} \u{2014} 99999 bytes, use read_overflow tool to retrieve]"
);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body,
compacted_at: None,
}],
));
let freed = agent.prune_tool_outputs(10);
assert!(freed > 0);
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = &agent.messages[1].parts[0]
{
assert!(compacted_at.is_some());
assert_eq!(
body,
&format!("[tool output pruned; use read_overflow {uuid} to retrieve]")
);
} else {
panic!("expected ToolOutput");
}
}
#[test]
fn prune_stale_tool_outputs_preserves_overflow_reference_in_tool_output() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let body = format!(
"truncated output\n[full output stored \u{2014} ID: {uuid} \u{2014} 99999 bytes, use read_overflow tool to retrieve]"
);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body,
compacted_at: None,
}],
));
for _ in 0..4 {
agent.messages.push(Message {
role: Role::User,
content: "recent".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let freed = agent.prune_stale_tool_outputs(4);
assert!(freed > 0);
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = &agent.messages[1].parts[0]
{
assert!(compacted_at.is_some());
assert_eq!(
body,
&format!("[tool output pruned; use read_overflow {uuid} to retrieve]")
);
} else {
panic!("expected ToolOutput");
}
}
#[test]
fn prune_stale_tool_outputs_preserves_overflow_reference_in_tool_result() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let content = format!(
"{}\n[full output stored \u{2014} ID: {uuid} \u{2014} 99999 bytes, use read_overflow tool to retrieve]",
"x".repeat(200)
);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t1".into(),
content,
is_error: false,
}],
));
for _ in 0..4 {
agent.messages.push(Message {
role: Role::User,
content: "recent".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let freed = agent.prune_stale_tool_outputs(4);
assert!(freed > 0);
if let MessagePart::ToolResult { content, .. } = &agent.messages[1].parts[0] {
assert_eq!(
content,
&format!("[tool output pruned; use read_overflow {uuid} to retrieve]")
);
} else {
panic!("expected ToolResult");
}
}
#[tokio::test]
async fn test_tier2_after_insufficient_prune() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_metrics(tx);
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} with enough content to push over budget threshold"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.maybe_compact().await.unwrap();
assert_eq!(rx.borrow().context_compactions, 1);
}
#[tokio::test]
async fn test_inject_cross_session_no_memory_noop() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let msg_count = agent.messages.len();
agent
.inject_cross_session_context("test", 1000)
.await
.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_inject_cross_session_zero_budget_noop() {
let provider = mock_provider(vec![]);
let (memory, cid) = create_memory_with_summaries(provider.clone(), &["summary"]).await;
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor).with_memory(
std::sync::Arc::new(memory),
cid,
50,
5,
50,
);
let msg_count = agent.messages.len();
agent.inject_cross_session_context("test", 0).await.unwrap();
assert_eq!(agent.messages.len(), msg_count);
}
#[tokio::test]
async fn test_remove_cross_session_messages() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message::from_parts(
Role::System,
vec![MessagePart::CrossSession {
text: "old cross-session".into(),
}],
),
);
assert_eq!(agent.messages.len(), 2);
agent.remove_cross_session_messages();
assert_eq!(agent.messages.len(), 1);
}
#[tokio::test]
async fn test_remove_cross_session_preserves_other_system() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.insert(
1,
Message::from_parts(
Role::System,
vec![MessagePart::Summary {
text: "keep this summary".into(),
}],
),
);
agent.messages.insert(
2,
Message::from_parts(
Role::System,
vec![MessagePart::CrossSession {
text: "remove this".into(),
}],
),
);
assert_eq!(agent.messages.len(), 3);
agent.remove_cross_session_messages();
assert_eq!(agent.messages.len(), 2);
assert!(agent.messages[1].content.contains("keep this summary"));
}
#[tokio::test]
async fn test_store_session_summary_on_compaction() {
let provider = mock_provider(vec!["compacted summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (memory, cid) = create_memory_with_summaries(provider.clone(), &[]).await;
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_memory(std::sync::Arc::new(memory), cid, 50, 5, 50)
.with_context_budget(10000, 0.20, 0.80, 2, 0);
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.compact_context().await.unwrap();
assert!(agent.messages[1].content.contains("compacted summary"));
}
#[test]
fn test_budget_allocation_cross_session() {
let budget = crate::context::ContextBudget::new(1000, 0.20);
let tc = zeph_memory::TokenCounter::new();
let alloc = budget.allocate("", "", &tc, false);
assert!(alloc.cross_session > 0);
assert!(alloc.summaries > 0);
assert!(alloc.semantic_recall > 0);
assert!(alloc.cross_session < alloc.summaries);
}
#[test]
fn test_cross_session_score_threshold_filters() {
use zeph_memory::semantic::SessionSummaryResult;
let threshold: f32 = 0.35;
let results = vec![
SessionSummaryResult {
summary_text: "high score".into(),
score: 0.9,
conversation_id: zeph_memory::ConversationId(1),
},
SessionSummaryResult {
summary_text: "at threshold".into(),
score: 0.35,
conversation_id: zeph_memory::ConversationId(2),
},
SessionSummaryResult {
summary_text: "below threshold".into(),
score: 0.2,
conversation_id: zeph_memory::ConversationId(3),
},
SessionSummaryResult {
summary_text: "way below".into(),
score: 0.0,
conversation_id: zeph_memory::ConversationId(4),
},
];
let filtered: Vec<_> = results
.into_iter()
.filter(|r| r.score >= threshold)
.collect();
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].summary_text, "high score");
assert_eq!(filtered[1].summary_text, "at threshold");
}
#[test]
fn context_budget_80_percent_threshold() {
let budget = ContextBudget::new(1000, 0.20);
let threshold = budget.max_tokens() * 4 / 5;
assert_eq!(threshold, 800);
assert!(800 >= threshold); assert!(799 < threshold); }
#[test]
fn prune_stale_tool_outputs_clears_old() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(10000, 0.20, 0.75, 4, 0)
.with_metrics(tx);
for i in 0..6 {
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: format!("tool_{i}"),
body: "x".repeat(200),
compacted_at: None,
}],
));
}
let freed = agent.prune_stale_tool_outputs(4);
assert!(freed > 0);
assert_eq!(rx.borrow().tool_output_prunes, 1);
for i in 1..3 {
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = &agent.messages[i].parts[0]
{
assert!(body.is_empty(), "message {i} should be pruned");
assert!(compacted_at.is_some());
}
}
for i in 3..7 {
if let MessagePart::ToolOutput { body, .. } = &agent.messages[i].parts[0] {
assert!(!body.is_empty(), "message {i} should be kept");
}
}
}
#[test]
fn prune_stale_tool_outputs_noop_when_few_messages() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body: "output".into(),
compacted_at: None,
}],
));
let freed = agent.prune_stale_tool_outputs(4);
assert_eq!(freed, 0);
}
#[test]
fn prune_stale_prunes_tool_result_too() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: "x".repeat(500),
is_error: false,
}],
));
for _ in 0..4 {
agent.messages.push(Message {
role: Role::User,
content: "recent".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let freed = agent.prune_stale_tool_outputs(4);
assert!(freed > 0);
if let MessagePart::ToolResult { content, .. } = &agent.messages[1].parts[0] {
assert_eq!(content, "[pruned]");
} else {
panic!("expected ToolResult");
}
}
#[test]
fn prune_stale_tool_outputs_multi_part_tool_result_counted_once_per_part() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.push(Message::from_parts(
Role::User,
vec![
MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: "x".repeat(500),
is_error: false,
},
MessagePart::ToolResult {
tool_use_id: "t2".into(),
content: "y".repeat(500),
is_error: false,
},
],
));
for _ in 0..4 {
agent.messages.push(Message {
role: Role::User,
content: "recent".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let freed = agent.prune_stale_tool_outputs(4);
assert!(freed > 0, "freed must reflect tokens from both parts");
if let MessagePart::ToolResult { content, .. } = &agent.messages[1].parts[0] {
assert_eq!(content, "[pruned]", "first ToolResult part must be pruned");
} else {
panic!("expected ToolResult at parts[0]");
}
if let MessagePart::ToolResult { content, .. } = &agent.messages[1].parts[1] {
assert_eq!(content, "[pruned]", "second ToolResult part must be pruned");
} else {
panic!("expected ToolResult at parts[1]");
}
}
#[tokio::test]
async fn test_prepare_context_scrubs_secrets_when_redact_enabled() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(4096, 0.20, 0.80, 4, 0)
.with_redact_credentials(true);
agent.messages.push(Message {
role: Role::User,
content: "my key is sk-abc123xyz and lives at /Users/dev/config.toml".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.prepare_context("test").await.unwrap();
let user_msg = agent
.messages
.iter()
.find(|m| m.role == Role::User)
.unwrap();
assert!(
!user_msg.content.contains("sk-abc123xyz"),
"secret must be redacted"
);
assert!(
!user_msg.content.contains("/Users/dev/"),
"path must be redacted"
);
assert!(
user_msg.content.contains("[REDACTED]"),
"secret replaced with [REDACTED]"
);
assert!(
user_msg.content.contains("[PATH]"),
"path replaced with [PATH]"
);
}
#[tokio::test]
async fn test_prepare_context_preserves_system_prompt_paths() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(4096, 0.20, 0.80, 4, 0)
.with_redact_credentials(true)
.with_working_dir("/Users/dev/project");
agent.messages.push(Message {
role: Role::User,
content: "debug /Users/dev/project/src/main.rs".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent
.rebuild_system_prompt("why is ACP not starting?")
.await;
agent
.prepare_context("why is ACP not starting?")
.await
.unwrap();
let system_msg = agent.messages.first().expect("system prompt must exist");
assert_eq!(system_msg.role, Role::System);
assert!(
system_msg
.content
.contains("working_directory: /Users/dev/project"),
"system prompt must keep the real working directory"
);
assert!(
!system_msg.content.contains("[PATH]"),
"system prompt must not leak placeholder paths into tool instructions"
);
let user_msg = agent
.messages
.iter()
.find(|m| m.role == Role::User)
.expect("user message must exist");
assert!(
user_msg.content.contains("[PATH]"),
"user history should still be scrubbed"
);
assert!(
!user_msg.content.contains("/Users/dev/project"),
"user history must not keep the absolute path"
);
}
#[tokio::test]
async fn test_prepare_context_no_scrub_when_redact_disabled() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(4096, 0.20, 0.80, 4, 0)
.with_redact_credentials(false);
let original = "key sk-abc123xyz at /Users/dev/file.rs".to_string();
agent.messages.push(Message {
role: Role::User,
content: original.clone(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.prepare_context("test").await.unwrap();
let user_msg = agent
.messages
.iter()
.find(|m| m.role == Role::User)
.unwrap();
assert_eq!(
user_msg.content, original,
"content must be unchanged when redact disabled"
);
}
#[test]
fn correction_prompt_does_not_replay_bad_path_commands() {
let note = crate::agent::Agent::<MockChannel>::format_correction_note(
"cd /Users/m/dev/zeph && grep -n \"acp\" Cargo.toml | head -40",
"Use the current repository and avoid hard-coded absolute paths.",
);
assert!(
!note.contains("cd /Users/m/dev/zeph"),
"correction prompt must not replay the faulty absolute-path command"
);
assert!(
!note.contains("[PATH]"),
"correction prompt must not inject literal path placeholders"
);
assert!(
note.contains("Use the current repository"),
"correction prompt must preserve the user correction guidance"
);
}
#[test]
fn compaction_tier_hard_triggers_when_cached_tokens_exceed_hard_threshold() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_soft_compaction_threshold(0.50);
agent.providers.cached_prompt_tokens = 900;
assert_eq!(
agent.compaction_tier(),
CompactionTier::Hard,
"cached_prompt_tokens above hard threshold must return Hard"
);
}
#[test]
fn compaction_tier_none_does_not_trigger_below_soft_threshold() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_soft_compaction_threshold(0.50);
agent.providers.cached_prompt_tokens = 100;
assert_eq!(
agent.compaction_tier(),
CompactionTier::None,
"cached_prompt_tokens below soft threshold must return None"
);
}
#[tokio::test]
async fn disambiguate_skills_reorders_on_match() {
let json = r#"{"skill_name":"beta_skill","confidence":0.9,"params":{}}"#;
let provider = mock_provider(vec![json.to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor);
let metas = [
SkillMeta {
name: "alpha_skill".into(),
description: "does alpha".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
SkillMeta {
name: "beta_skill".into(),
description: "does beta".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
];
let refs: Vec<&SkillMeta> = metas.iter().collect();
let scored = vec![
ScoredMatch {
index: 0,
score: 0.90,
},
ScoredMatch {
index: 1,
score: 0.88,
},
];
let result = agent
.disambiguate_skills("do beta stuff", &refs, &scored)
.await;
assert!(result.is_some());
let indices = result.unwrap();
assert_eq!(indices[0], 1); }
#[tokio::test]
async fn disambiguate_skills_returns_none_on_error() {
let provider = mock_provider_failing();
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor);
let metas = [SkillMeta {
name: "test".into(),
description: "test".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
}];
let refs: Vec<&SkillMeta> = metas.iter().collect();
let scored = vec![ScoredMatch {
index: 0,
score: 0.5,
}];
let result = agent.disambiguate_skills("query", &refs, &scored).await;
assert!(result.is_none());
}
#[tokio::test]
async fn disambiguate_skills_empty_candidates() {
let json = r#"{"skill_name":"none","confidence":0.1,"params":{}}"#;
let provider = mock_provider(vec![json.to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor);
let metas: [SkillMeta; 0] = [];
let refs: Vec<&SkillMeta> = metas.iter().collect();
let scored: Vec<ScoredMatch> = vec![];
let result = agent.disambiguate_skills("query", &refs, &scored).await;
assert!(result.is_some());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn disambiguate_skills_unknown_skill_preserves_order() {
let json = r#"{"skill_name":"nonexistent","confidence":0.5,"params":{}}"#;
let provider = mock_provider(vec![json.to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor);
let metas = [
SkillMeta {
name: "first".into(),
description: "first skill".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
SkillMeta {
name: "second".into(),
description: "second skill".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
];
let refs: Vec<&SkillMeta> = metas.iter().collect();
let scored = vec![
ScoredMatch {
index: 0,
score: 0.9,
},
ScoredMatch {
index: 1,
score: 0.88,
},
];
let result = agent
.disambiguate_skills("query", &refs, &scored)
.await
.unwrap();
assert_eq!(result[0], 0);
assert_eq!(result[1], 1);
}
#[tokio::test]
async fn disambiguate_single_candidate_no_swap() {
let json = r#"{"skill_name":"only_skill","confidence":0.95,"params":{}}"#;
let provider = mock_provider(vec![json.to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent = Agent::new(provider, channel, registry, None, 5, executor);
let metas = [SkillMeta {
name: "only_skill".into(),
description: "the only one".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
}];
let refs: Vec<&SkillMeta> = metas.iter().collect();
let scored = vec![ScoredMatch {
index: 0,
score: 0.95,
}];
let result = agent
.disambiguate_skills("query", &refs, &scored)
.await
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0], 0);
}
#[tokio::test]
async fn rebuild_system_prompt_excludes_skill_when_secret_missing() {
use std::collections::HashMap;
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let meta_with_secret = SkillMeta {
name: "secure-skill".into(),
description: "needs a secret".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["my_api_key".into()],
skill_dir: std::path::PathBuf::new(),
};
agent.skill_state.available_custom_secrets = HashMap::new();
let all_meta = [meta_with_secret];
let matched_indices: Vec<usize> = vec![0];
let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();
assert!(
filtered.is_empty(),
"skill must be excluded when required secret is missing"
);
}
#[tokio::test]
async fn rebuild_system_prompt_includes_skill_when_secret_present() {
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let meta_with_secret = SkillMeta {
name: "secure-skill".into(),
description: "needs a secret".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["my_api_key".into()],
skill_dir: std::path::PathBuf::new(),
};
agent
.skill_state
.available_custom_secrets
.insert("my_api_key".into(), crate::vault::Secret::new("token-val"));
let all_meta = [meta_with_secret];
let matched_indices: Vec<usize> = vec![0];
let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();
assert_eq!(
filtered,
vec![0],
"skill must be included when required secret is present"
);
}
#[tokio::test]
async fn rebuild_system_prompt_excludes_skill_when_only_partial_secrets_present() {
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let meta = SkillMeta {
name: "multi-secret-skill".into(),
description: "needs two secrets".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["secret_a".into(), "secret_b".into()],
skill_dir: std::path::PathBuf::new(),
};
agent
.skill_state
.available_custom_secrets
.insert("secret_a".into(), crate::vault::Secret::new("val-a"));
let all_meta = [meta];
let matched_indices: Vec<usize> = vec![0];
let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();
assert!(
filtered.is_empty(),
"skill must be excluded when only partial secrets are available"
);
}
fn make_tool_result_message(content: &str) -> Message {
Message::from_parts(
Role::User,
vec![zeph_llm::provider::MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: content.into(),
is_error: false,
}],
)
}
fn make_text_message(text: &str) -> Message {
Message::from_legacy(Role::User, text)
}
#[test]
fn remove_tool_responses_empty_messages_unchanged() {
let msgs: Vec<Message> = vec![];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 1.0);
assert!(result.is_empty());
}
#[test]
fn remove_tool_responses_no_tool_messages_unchanged() {
let msgs = vec![make_text_message("hello"), make_text_message("world")];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 1.0);
assert_eq!(result.len(), 2);
assert_eq!(result[0].content, "hello");
}
#[test]
fn remove_tool_responses_100_percent_clears_all() {
let msgs = vec![
make_tool_result_message("result1"),
make_tool_result_message("result2"),
make_tool_result_message("result3"),
];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 1.0);
assert_eq!(result.len(), 3);
for msg in &result {
if let Some(zeph_llm::provider::MessagePart::ToolResult { content, .. }) =
msg.parts.first()
{
assert_eq!(content, "[compacted]");
}
}
}
#[test]
fn remove_tool_responses_50_percent_removes_half() {
let msgs = vec![
make_tool_result_message("r1"),
make_tool_result_message("r2"),
make_tool_result_message("r3"),
make_tool_result_message("r4"),
];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 0.5);
let compacted = result
.iter()
.filter(|m| {
m.parts.first().is_some_and(|p| {
matches!(p, zeph_llm::provider::MessagePart::ToolResult { content, .. } if content == "[compacted]")
})
})
.count();
assert_eq!(compacted, 2);
}
#[test]
fn build_metadata_summary_includes_counts() {
let msgs = vec![
make_text_message("user question"),
Message::from_legacy(Role::Assistant, "assistant response"),
];
let summary = Agent::<MockChannel>::build_metadata_summary(&msgs);
assert!(summary.contains('2'));
assert!(summary.contains("1 user"));
assert!(summary.contains("1 assistant"));
}
#[test]
fn remove_tool_responses_middle_out_order_is_center_first() {
let msgs: Vec<Message> = (0..5)
.map(|i| {
Message::from_parts(
Role::User,
vec![zeph_llm::provider::MessagePart::ToolResult {
tool_use_id: format!("t{i}"),
content: format!("result{i}"),
is_error: false,
}],
)
})
.collect();
let is_compacted = |msgs: &[Message], idx: usize| -> bool {
msgs[idx].parts.first().is_some_and(|p| {
matches!(p, zeph_llm::provider::MessagePart::ToolResult { content, .. } if content == "[compacted]")
})
};
let one = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs.clone(), 0.20);
assert!(
is_compacted(&one, 2),
"center (idx 2) must be first removed"
);
assert!(!is_compacted(&one, 0));
assert!(!is_compacted(&one, 1));
assert!(!is_compacted(&one, 3));
assert!(!is_compacted(&one, 4));
let two = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs.clone(), 0.40);
assert!(is_compacted(&two, 2));
assert!(is_compacted(&two, 1));
assert!(!is_compacted(&two, 0));
assert!(!is_compacted(&two, 3));
assert!(!is_compacted(&two, 4));
let three = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 0.60);
assert!(is_compacted(&three, 2));
assert!(is_compacted(&three, 1));
assert!(is_compacted(&three, 3));
assert!(!is_compacted(&three, 0));
assert!(!is_compacted(&three, 4));
}
#[test]
fn truncate_chars_is_safe_for_multibyte() {
let s: String = "Привет".repeat(50); let truncated = super::truncate_chars(&s, 200);
assert!(truncated.ends_with('…'));
assert_eq!(truncated.chars().count(), 201); }
#[test]
fn truncate_chars_ascii_exact() {
let s = "abcde";
let result = super::truncate_chars(s, 5);
assert_eq!(result, "abcde");
}
#[test]
fn truncate_chars_emoji() {
let s = "🚀🚀🚀🚀🚀";
let result = super::truncate_chars(s, 3);
assert!(result.ends_with('…'), "should append ellipsis");
assert_eq!(result.chars().count(), 4);
}
#[test]
fn truncate_chars_empty() {
let result = super::truncate_chars("", 10);
assert_eq!(result, "");
}
#[test]
fn truncate_chars_shorter_than_max() {
let s = "hello";
let result = super::truncate_chars(s, 100);
assert_eq!(result, "hello");
}
#[test]
fn truncate_chars_zero_max() {
let s = "hello";
let result = super::truncate_chars(s, 0);
assert_eq!(result, "");
}
#[test]
fn build_chunk_prompt_contains_all_nine_sections() {
let messages = vec![Message {
role: Role::User,
content: "help me refactor this code".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let prompt = Agent::<MockChannel>::build_chunk_prompt(&messages, "");
let sections = [
"User Intent",
"Technical Concepts",
"Files & Code",
"Errors & Fixes",
"Problem Solving",
"User Messages",
"Pending Tasks",
"Current Work",
"Next Step",
];
for section in sections {
assert!(
prompt.contains(section),
"prompt missing section: {section}"
);
}
}
#[test]
fn build_chunk_prompt_empty_messages() {
let messages: &[Message] = &[];
let prompt = Agent::<MockChannel>::build_chunk_prompt(messages, "");
assert!(prompt.contains("User Intent"));
assert!(prompt.contains("Next Step"));
}
#[test]
fn build_chunk_prompt_injects_guidelines_block_when_non_empty() {
let messages: &[Message] = &[];
let guidelines = "1. Preserve file paths\n2. Preserve error codes";
let prompt = Agent::<MockChannel>::build_chunk_prompt(messages, guidelines);
assert!(
prompt.contains("<compression-guidelines>"),
"guidelines block must be present when guidelines non-empty"
);
assert!(
prompt.contains("Preserve file paths"),
"guideline content must appear in prompt"
);
assert!(
prompt.contains("</compression-guidelines>"),
"guidelines closing tag must be present"
);
}
#[test]
fn build_chunk_prompt_no_guidelines_block_when_empty() {
let messages: &[Message] = &[];
let prompt = Agent::<MockChannel>::build_chunk_prompt(messages, "");
assert!(
!prompt.contains("<compression-guidelines>"),
"no guidelines block when guidelines is empty"
);
}
#[tokio::test]
async fn rebuild_system_prompt_stable_marker_before_volatile_marker() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.rebuild_system_prompt("test query").await;
let prompt = &agent.messages[0].content;
let pos_stable = prompt
.find("<!-- cache:stable -->")
.expect("cache:stable marker must be present");
let pos_volatile = prompt
.find("<!-- cache:volatile -->")
.expect("cache:volatile marker must be present");
assert!(
pos_stable < pos_volatile,
"cache:stable must appear before cache:volatile in the system prompt"
);
}
#[tokio::test]
async fn rebuild_system_prompt_base_content_before_stable_marker() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.rebuild_system_prompt("test query").await;
let prompt = &agent.messages[0].content;
let pos_stable = prompt
.find("<!-- cache:stable -->")
.expect("cache:stable marker must be present");
let before_stable = prompt[..pos_stable].trim();
assert!(
!before_stable.is_empty(),
"base prompt content must appear before cache:stable marker"
);
}
#[tokio::test]
async fn rebuild_system_prompt_volatile_marker_at_block3_boundary() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.rebuild_system_prompt("test query").await;
let prompt = &agent.messages[0].content;
let pos_volatile = prompt
.find("<!-- cache:volatile -->")
.expect("cache:volatile marker must be present");
let after_volatile = &prompt[pos_volatile + "<!-- cache:volatile -->".len()..];
assert!(
!after_volatile.contains("<!-- cache:stable -->"),
"cache:stable must not appear after cache:volatile"
);
}
#[test]
fn build_metadata_summary_empty_messages() {
let messages: &[Message] = &[];
let summary = Agent::<MockChannel>::build_metadata_summary(messages);
assert!(summary.contains("Messages compacted: 0"));
assert!(summary.contains("0 user"));
assert!(summary.contains("0 assistant"));
}
#[test]
fn build_metadata_summary_utf8_content() {
let messages = vec![
Message {
role: Role::User,
content: "Привет мир 🌍".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "Hello 🌐".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let summary = Agent::<MockChannel>::build_metadata_summary(&messages);
assert!(summary.contains("Messages compacted: 2"));
assert!(summary.contains("1 user"));
assert!(summary.contains("1 assistant"));
}
#[test]
fn build_metadata_summary_truncation_boundary() {
let long_content = "a".repeat(300);
let messages = vec![Message {
role: Role::User,
content: long_content,
parts: vec![],
metadata: MessageMetadata::default(),
}];
let summary = Agent::<MockChannel>::build_metadata_summary(&messages);
assert!(
summary.contains('…'),
"long content should be truncated with ellipsis"
);
}
#[test]
fn remove_tool_responses_single_tool_message() {
let msg = Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: "result".into(),
is_error: false,
}],
);
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(vec![msg], 1.0);
assert_eq!(result.len(), 1);
if let MessagePart::ToolResult { content, .. } = &result[0].parts[0] {
assert_eq!(content, "[compacted]");
} else {
panic!("expected ToolResult part");
}
}
#[test]
fn remove_tool_responses_all_tiers_progressive() {
let make_tool_msg = |i: usize| {
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: format!("t{i}"),
content: format!("result_{i}"),
is_error: false,
}],
)
};
let msgs: Vec<Message> = (0..10).map(make_tool_msg).collect();
let count_compacted = |result: &[Message]| {
result
.iter()
.filter(|m| {
m.parts.iter().any(|p| {
matches!(p, MessagePart::ToolResult { content, .. } if content == "[compacted]")
})
})
.count()
};
let r10 = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs.clone(), 0.10);
assert_eq!(count_compacted(&r10), 1);
let r20 = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs.clone(), 0.20);
assert_eq!(count_compacted(&r20), 2);
let r50 = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs.clone(), 0.50);
assert_eq!(count_compacted(&r50), 5);
let r100 = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 1.0);
assert_eq!(count_compacted(&r100), 10);
}
fn make_tool_pair(agent: &mut Agent<MockChannel>, tool_name: &str) {
agent.messages.push(Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: format!("id_{tool_name}"),
name: tool_name.to_owned(),
input: serde_json::json!({"cmd": "echo hello"}),
}],
));
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: format!("id_{tool_name}"),
content: format!("output of {tool_name}"),
is_error: false,
}],
));
}
#[test]
fn count_unsummarized_pairs_counts_visible_native_pairs() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
assert_eq!(agent.count_unsummarized_pairs(), 0);
make_tool_pair(&mut agent, "bash");
assert_eq!(agent.count_unsummarized_pairs(), 1);
make_tool_pair(&mut agent, "read_file");
assert_eq!(agent.count_unsummarized_pairs(), 2);
}
#[test]
fn count_unsummarized_pairs_ignores_hidden_pairs() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "bash");
agent.messages[1].metadata.agent_visible = false;
agent.messages[2].metadata.agent_visible = false;
assert_eq!(agent.count_unsummarized_pairs(), 0);
}
#[test]
fn find_oldest_unsummarized_pair_returns_correct_indices() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
assert_eq!(agent.find_oldest_unsummarized_pair(), None);
make_tool_pair(&mut agent, "bash");
assert_eq!(agent.find_oldest_unsummarized_pair(), Some((1, 2)));
make_tool_pair(&mut agent, "read_file");
assert_eq!(agent.find_oldest_unsummarized_pair(), Some((1, 2)));
}
#[test]
fn find_oldest_unsummarized_pair_skips_hidden() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "bash");
make_tool_pair(&mut agent, "read_file");
agent.messages[1].metadata.agent_visible = false;
agent.messages[2].metadata.agent_visible = false;
assert_eq!(agent.find_oldest_unsummarized_pair(), Some((3, 4)));
}
#[tokio::test]
async fn maybe_summarize_tool_pair_below_cutoff_does_nothing() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(6);
make_tool_pair(&mut agent, "bash");
make_tool_pair(&mut agent, "read_file");
make_tool_pair(&mut agent, "write_file");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
assert_eq!(agent.messages.len(), msg_count_before);
}
#[tokio::test]
async fn maybe_summarize_tool_pair_at_exact_cutoff_does_nothing() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(3);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
make_tool_pair(&mut agent, "c");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
assert_eq!(agent.messages.len(), msg_count_before);
}
#[tokio::test]
async fn maybe_summarize_tool_pair_above_cutoff_stores_deferred_summary() {
let summary_text = "summarized tool call".to_owned();
let provider = mock_provider(vec![summary_text.clone()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(2);
make_tool_pair(&mut agent, "bash");
make_tool_pair(&mut agent, "read_file");
make_tool_pair(&mut agent, "write_file");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
assert_eq!(agent.messages.len(), msg_count_before);
assert!(agent.messages[1].metadata.agent_visible);
assert!(agent.messages[2].metadata.agent_visible);
assert_eq!(
agent.messages[2].metadata.deferred_summary.as_deref(),
Some(summary_text.as_str()),
"deferred_summary should hold the LLM response"
);
}
#[tokio::test]
async fn maybe_summarize_tool_pair_drains_backlog_above_cutoff() {
let replies = vec!["s1".into(), "s2".into(), "s3".into(), "s4".into()];
let provider = mock_provider(replies);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(2);
for name in ["a", "b", "c", "d", "e", "f"] {
make_tool_pair(&mut agent, name);
}
agent.maybe_summarize_tool_pair().await;
let deferred_count = agent
.messages
.iter()
.filter(|m| m.metadata.deferred_summary.is_some())
.count();
assert_eq!(
deferred_count, 4,
"expected 4 deferred summaries, got {deferred_count}"
);
assert_eq!(agent.count_unsummarized_pairs(), 2);
}
#[tokio::test]
async fn maybe_summarize_tool_pair_llm_error_skips_gracefully() {
let provider = mock_provider_failing();
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(1);
make_tool_pair(&mut agent, "bash");
make_tool_pair(&mut agent, "read_file");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
assert_eq!(agent.messages.len(), msg_count_before);
assert!(agent.messages[1].metadata.agent_visible);
assert!(agent.messages[2].metadata.agent_visible);
}
#[test]
fn build_tool_pair_summary_prompt_contains_xml_delimiters() {
let req = Message {
role: Role::Assistant,
content: "call bash".into(),
..Message::default()
};
let res = Message {
role: Role::User,
content: "exit code 0".into(),
..Message::default()
};
let prompt = Agent::<MockChannel>::build_tool_pair_summary_prompt(&req, &res);
assert!(prompt.contains("<tool_request>"), "missing <tool_request>");
assert!(
prompt.contains("</tool_request>"),
"missing </tool_request>"
);
assert!(
prompt.contains("<tool_response>"),
"missing <tool_response>"
);
assert!(
prompt.contains("</tool_response>"),
"missing </tool_response>"
);
assert!(prompt.contains("call bash"));
assert!(prompt.contains("exit code 0"));
}
#[tokio::test]
async fn maybe_summarize_tool_pair_empty_messages_does_nothing() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(1);
agent.messages.clear();
agent.maybe_summarize_tool_pair().await;
assert!(agent.messages.is_empty());
}
#[test]
fn remove_tool_responses_fraction_zero_changes_nothing() {
let msgs = vec![
make_tool_result_message("result1"),
make_tool_result_message("result2"),
];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 0.0);
assert_eq!(result.len(), 2);
for msg in &result {
if let Some(MessagePart::ToolResult { content, .. }) = msg.parts.first() {
assert_ne!(
content, "[compacted]",
"fraction=0.0 should not compact anything"
);
}
}
}
#[test]
fn remove_tool_responses_tool_output_parts_compacted() {
let msgs = vec![
Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body: "output text".into(),
compacted_at: None,
}],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "read_file".into(),
body: "file content".into(),
compacted_at: None,
}],
),
];
let result = Agent::<MockChannel>::remove_tool_responses_middle_out(msgs, 1.0);
assert_eq!(result.len(), 2);
for msg in &result {
if let Some(MessagePart::ToolOutput {
body, compacted_at, ..
}) = msg.parts.first()
{
assert!(
body.is_empty(),
"ToolOutput body should be cleared after compaction"
);
assert!(
compacted_at.is_some(),
"ToolOutput compacted_at should be set"
);
} else {
panic!("expected ToolOutput part");
}
}
}
#[tokio::test]
async fn tier1_compaction_emits_compacting_status() {
use std::sync::Arc;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let statuses = Arc::clone(&channel.statuses);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0);
for i in 0..5 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} padding to exceed budget threshold padding padding"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.maybe_compact().await.unwrap();
let emitted = statuses.lock().unwrap().clone();
assert!(
emitted.iter().any(|s| s == "compacting context..."),
"expected 'compacting context...' in statuses, got: {emitted:?}"
);
}
#[tokio::test]
async fn prepare_context_emits_recalling_status() {
use std::sync::Arc;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let statuses = Arc::clone(&channel.statuses);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(10_000, 0.80, 0.75, 2, 0);
agent.prepare_context("test query").await.unwrap();
let emitted = statuses.lock().unwrap().clone();
assert!(
emitted.iter().any(|s| s == "recalling context..."),
"expected 'recalling context...' in statuses, got: {emitted:?}"
);
}
#[test]
fn cap_summary_short_string_unchanged() {
let s = "hello world".to_owned();
let result = cap_summary(s.clone(), 100);
assert_eq!(result, s);
}
#[test]
fn cap_summary_truncates_long_string() {
let s = "a".repeat(200);
let result = cap_summary(s, 10);
assert!(result.ends_with('…'));
assert_eq!(result.chars().count(), 11); }
#[test]
fn cap_summary_exact_length_unchanged() {
let s = "hello".to_owned();
let result = cap_summary(s.clone(), 5);
assert_eq!(result, s);
}
#[tokio::test]
async fn compacted_this_turn_reset_between_turns() {
let provider = mock_provider(vec!["turn1".to_owned(), "turn2".to_owned()]);
let channel = MockChannel::new(vec!["first".to_owned(), "second".to_owned()]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.context_manager.compacted_this_turn = true;
let _ = agent.process_user_message("first".to_owned(), vec![]).await;
assert!(!agent.context_manager.compacted_this_turn);
}
#[tokio::test]
async fn maybe_proactive_compress_does_not_fire_with_reactive_strategy() {
let provider = mock_provider(vec!["response".to_owned()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.providers.cached_prompt_tokens = 200_000;
let result = agent.maybe_proactive_compress().await;
assert!(result.is_ok());
assert!(!agent.context_manager.compacted_this_turn);
}
#[test]
fn budget_allocation_graph_disabled_preserves_semantic_recall_8pct() {
let budget = crate::context::ContextBudget::new(10000, 0.20);
let tc = zeph_memory::TokenCounter::new();
let alloc = budget.allocate("", "", &tc, false);
assert_eq!(alloc.graph_facts, 0);
let available = 10000 - 2000; let expected_recall = available * 8 / 100;
assert_eq!(alloc.semantic_recall, expected_recall);
}
#[test]
fn budget_allocation_graph_enabled_splits_from_semantic_recall() {
let budget = crate::context::ContextBudget::new(10000, 0.20);
let tc = zeph_memory::TokenCounter::new();
let alloc = budget.allocate("", "", &tc, true);
assert!(
alloc.graph_facts > 0,
"graph_facts must be non-zero when enabled"
);
assert!(alloc.graph_facts < alloc.semantic_recall, "3% < 5%");
}
#[test]
fn budget_allocation_zero_tokens_graph_facts_zero() {
let budget = crate::context::ContextBudget::new(0, 0.20);
let tc = zeph_memory::TokenCounter::new();
let alloc = budget.allocate("", "", &tc, true);
assert_eq!(alloc.graph_facts, 0);
}
fn make_tool_pair_with_output(agent: &mut Agent<MockChannel>, tool_name: &str) {
agent.messages.push(Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: format!("id_{tool_name}"),
name: tool_name.to_owned(),
input: serde_json::json!({"cmd": "echo hello"}),
}],
));
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: tool_name.to_owned(),
body: format!("full output of {tool_name}"),
compacted_at: None,
}],
));
}
#[tokio::test]
async fn summarize_then_prune_preserves_intact_content_for_summarizer() {
let summary_text = "summarized bash call".to_owned();
let provider = mock_provider(vec![summary_text.clone()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(2);
make_tool_pair_with_output(&mut agent, "bash");
make_tool_pair_with_output(&mut agent, "read_file");
make_tool_pair_with_output(&mut agent, "write_file");
agent.maybe_summarize_tool_pair().await;
agent.apply_deferred_summaries();
let keep_recent = 2 * agent.memory_state.tool_call_cutoff + 2;
agent.prune_stale_tool_outputs(keep_recent);
let has_summary = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(has_summary, "summary should have been inserted");
assert!(
!agent.messages[1].metadata.agent_visible,
"oldest pair request should be hidden"
);
assert!(
!agent.messages[2].metadata.agent_visible,
"oldest pair response should be hidden"
);
}
#[tokio::test]
async fn prune_after_summarize_does_not_destroy_visible_pairs() {
let summary_text = "summary".to_owned();
let provider = mock_provider(vec![summary_text]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(2);
make_tool_pair_with_output(&mut agent, "bash");
make_tool_pair_with_output(&mut agent, "read_file");
make_tool_pair_with_output(&mut agent, "write_file");
agent.maybe_summarize_tool_pair().await;
agent.apply_deferred_summaries();
let keep_recent = 2 * agent.memory_state.tool_call_cutoff + 2;
agent.prune_stale_tool_outputs(keep_recent);
for msg in agent.messages.iter().filter(|m| m.metadata.agent_visible) {
for part in &msg.parts {
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = part
{
assert!(
!body.is_empty() || compacted_at.is_some(),
"visible pair should not have empty body without compacted_at"
);
assert!(
compacted_at.is_none(),
"visible pairs within keep_recent window must not be pruned"
);
}
}
}
}
#[tokio::test]
async fn prune_then_summarize_regression_summarizer_sees_pruned_content() {
let summary_text = "summary of pruned pair".to_owned();
let provider = mock_provider(vec![summary_text]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(1);
make_tool_pair_with_output(&mut agent, "bash");
make_tool_pair_with_output(&mut agent, "read_file");
let small_keep_recent = 2;
agent.prune_stale_tool_outputs(small_keep_recent);
let first_output_pruned = agent.messages[2].parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolOutput {
compacted_at: Some(_),
..
}
)
});
assert!(
first_output_pruned,
"pruning before summarization should have compacted the first pair's output"
);
}
#[tokio::test]
async fn cutoff_one_edge_case_summarize_then_prune() {
let summary_text = "summary".to_owned();
let provider = mock_provider(vec![summary_text]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(1);
make_tool_pair_with_output(&mut agent, "bash");
make_tool_pair_with_output(&mut agent, "read_file");
agent.maybe_summarize_tool_pair().await;
agent.apply_deferred_summaries();
let keep_recent = 2 * agent.memory_state.tool_call_cutoff + 2;
agent.prune_stale_tool_outputs(keep_recent);
let has_summary = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(has_summary, "summary should have been created for cutoff=1");
let visible_outputs: Vec<_> = agent
.messages
.iter()
.filter(|m| m.metadata.agent_visible)
.flat_map(|m| m.parts.iter())
.filter(|p| matches!(p, MessagePart::ToolOutput { .. }))
.collect();
for part in &visible_outputs {
if let MessagePart::ToolOutput { compacted_at, .. } = part {
assert!(
compacted_at.is_none(),
"visible pair within keep_recent must not be pruned (cutoff=1)"
);
}
}
}
#[tokio::test]
async fn summarizer_failure_prune_still_runs() {
let provider = mock_provider_failing();
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(1);
make_tool_pair_with_output(&mut agent, "bash");
make_tool_pair_with_output(&mut agent, "read_file");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
let keep_recent = 2 * agent.memory_state.tool_call_cutoff + 2;
let freed = agent.prune_stale_tool_outputs(keep_recent);
assert_eq!(agent.messages.len(), msg_count_before);
assert_eq!(freed, 0, "keep_recent=4 should protect all 4 tool messages");
}
async fn build_graph_memory() -> zeph_memory::semantic::SemanticMemory {
let mem = zeph_memory::semantic::SemanticMemory::new(
":memory:",
"http://127.0.0.1:1",
AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
"test-model",
)
.await
.unwrap();
let store = std::sync::Arc::new(zeph_memory::graph::GraphStore::new(
mem.sqlite().pool().clone(),
));
mem.with_graph_store(store)
}
fn make_mem_state(
memory: std::sync::Arc<zeph_memory::semantic::SemanticMemory>,
cid: zeph_memory::ConversationId,
graph_enabled: bool,
) -> MemoryState {
MemoryState {
memory: Some(memory),
conversation_id: Some(cid),
history_limit: 50,
recall_limit: 5,
summarization_threshold: 100,
cross_session_score_threshold: 0.5,
autosave_assistant: false,
autosave_min_length: 20,
tool_call_cutoff: 6,
unsummarized_count: 0,
document_config: crate::config::DocumentConfig::default(),
graph_config: crate::config::GraphConfig {
enabled: graph_enabled,
..Default::default()
},
compression_guidelines_config: zeph_memory::CompressionGuidelinesConfig::default(),
shutdown_summary: true,
shutdown_summary_min_messages: 4,
shutdown_summary_max_messages: 20,
shutdown_summary_timeout_secs: 10,
}
}
#[tokio::test]
async fn fetch_graph_facts_returns_none_when_graph_config_disabled() {
let memory = build_graph_memory().await;
let cid = memory.sqlite().create_conversation().await.unwrap();
let mem_state = make_mem_state(std::sync::Arc::new(memory), cid, false);
let tc = std::sync::Arc::new(zeph_memory::TokenCounter::new());
let result = Agent::<MockChannel>::fetch_graph_facts(&mem_state, "test", 1000, &tc)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn fetch_graph_facts_returns_none_when_budget_zero() {
let memory = build_graph_memory().await;
let cid = memory.sqlite().create_conversation().await.unwrap();
let mem_state = make_mem_state(std::sync::Arc::new(memory), cid, true);
let tc = std::sync::Arc::new(zeph_memory::TokenCounter::new());
let result = Agent::<MockChannel>::fetch_graph_facts(&mem_state, "test", 0, &tc)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn fetch_graph_facts_returns_none_when_graph_is_empty() {
let memory = build_graph_memory().await;
let cid = memory.sqlite().create_conversation().await.unwrap();
let mem_state = make_mem_state(std::sync::Arc::new(memory), cid, true);
let tc = std::sync::Arc::new(zeph_memory::TokenCounter::new());
let result = Agent::<MockChannel>::fetch_graph_facts(&mem_state, "rust", 1000, &tc)
.await
.unwrap();
assert!(result.is_none(), "empty graph must return None");
}
#[tokio::test]
async fn deferred_summary_stored_not_applied() {
let summary_text = "deferred result".to_owned();
let provider = mock_provider(vec![summary_text.clone()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent =
Agent::new(provider, channel, registry, None, 5, executor).with_tool_call_cutoff(2);
make_tool_pair(&mut agent, "bash");
make_tool_pair(&mut agent, "read_file");
make_tool_pair(&mut agent, "write_file");
let msg_count_before = agent.messages.len();
agent.maybe_summarize_tool_pair().await;
assert_eq!(agent.messages.len(), msg_count_before);
for msg in &agent.messages {
assert!(
msg.metadata.agent_visible,
"no message should be hidden after deferred storage"
);
}
assert!(
agent.messages[2].metadata.deferred_summary.is_some(),
"deferred_summary must be set on response message"
);
}
#[test]
fn count_unsummarized_pairs_excludes_deferred() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
make_tool_pair(&mut agent, "c");
make_tool_pair(&mut agent, "d");
agent.messages[2].metadata.deferred_summary = Some("s1".into());
agent.messages[4].metadata.deferred_summary = Some("s2".into());
assert_eq!(agent.count_unsummarized_pairs(), 2);
}
#[test]
fn find_oldest_unsummarized_pair_skips_deferred() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "first");
make_tool_pair(&mut agent, "second");
make_tool_pair(&mut agent, "third");
agent.messages[2].metadata.deferred_summary = Some("already queued".into());
assert_eq!(agent.find_oldest_unsummarized_pair(), Some((3, 4)));
}
#[test]
fn count_deferred_summaries_correct() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
make_tool_pair(&mut agent, "c");
assert_eq!(agent.count_deferred_summaries(), 0);
agent.messages[2].metadata.deferred_summary = Some("s1".into());
agent.messages[4].metadata.deferred_summary = Some("s2".into());
agent.messages[6].metadata.deferred_summary = Some("s3".into());
assert_eq!(agent.count_deferred_summaries(), 3);
}
#[test]
fn apply_deferred_summaries_batch() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
make_tool_pair(&mut agent, "c");
agent.messages[2].metadata.deferred_summary = Some("sum_a".into());
agent.messages[4].metadata.deferred_summary = Some("sum_b".into());
agent.messages[6].metadata.deferred_summary = Some("sum_c".into());
let applied = agent.apply_deferred_summaries();
assert_eq!(applied, 3);
let hidden = agent
.messages
.iter()
.filter(|m| !m.metadata.agent_visible)
.count();
assert_eq!(hidden, 6);
let summaries = agent
.messages
.iter()
.filter(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
})
.count();
assert_eq!(summaries, 3);
for msg in &agent.messages {
assert!(msg.metadata.deferred_summary.is_none());
}
}
#[test]
fn apply_deferred_summaries_empty() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
let msg_count_before = agent.messages.len();
let applied = agent.apply_deferred_summaries();
assert_eq!(applied, 0);
assert_eq!(agent.messages.len(), msg_count_before);
}
#[test]
fn apply_deferred_summaries_reverse_order() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
make_tool_pair(&mut agent, "c");
make_tool_pair(&mut agent, "d");
make_tool_pair(&mut agent, "e");
agent.messages[6].metadata.deferred_summary = Some("sum_c".into());
agent.messages[10].metadata.deferred_summary = Some("sum_e".into());
let applied = agent.apply_deferred_summaries();
assert_eq!(applied, 2);
assert!(!agent.messages[5].metadata.agent_visible);
assert!(!agent.messages[6].metadata.agent_visible);
let hidden = agent
.messages
.iter()
.filter(|m| !m.metadata.agent_visible)
.count();
assert_eq!(hidden, 4);
let summaries = agent
.messages
.iter()
.filter(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
})
.count();
assert_eq!(summaries, 2);
}
#[test]
fn tier0_does_not_set_compacted_this_turn() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.80, 4, 0)
.with_soft_compaction_threshold(0.70);
make_tool_pair(&mut agent, "a");
make_tool_pair(&mut agent, "b");
agent.messages[2].metadata.deferred_summary = Some("s".into());
agent.providers.cached_prompt_tokens = 75_000;
assert!(!agent.context_manager.compacted_this_turn);
agent.maybe_apply_deferred_summaries();
assert!(
!agent.context_manager.compacted_this_turn,
"tier-0 must not set compacted_this_turn"
);
}
#[test]
fn tier0_count_trigger_fires_without_budget_pressure() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.80, 4, 0)
.with_soft_compaction_threshold(0.70);
for label in ["a", "b", "c", "d", "e", "f"] {
make_tool_pair(&mut agent, label);
}
agent.messages[2].metadata.deferred_summary = Some("s_a".into());
agent.messages[4].metadata.deferred_summary = Some("s_b".into());
agent.messages[6].metadata.deferred_summary = Some("s_c".into());
agent.messages[8].metadata.deferred_summary = Some("s_d".into());
agent.messages[10].metadata.deferred_summary = Some("s_e".into());
agent.messages[12].metadata.deferred_summary = Some("s_f".into());
agent.providers.cached_prompt_tokens = 5_000;
agent.maybe_apply_deferred_summaries();
let summaries = agent
.messages
.iter()
.filter(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
})
.count();
assert_eq!(
summaries, 6,
"count trigger must apply all 6 deferred summaries"
);
}
#[test]
fn find_oldest_unsummarized_skips_pruned_content() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.push(Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "id_pruned".into(),
name: "bash".into(),
input: serde_json::json!({}),
}],
));
agent.messages.push(Message::from_parts(
Role::User,
vec![MessagePart::ToolOutput {
tool_name: "bash".into(),
body: String::new(), compacted_at: None,
}],
));
make_tool_pair(&mut agent, "real_tool");
assert_eq!(
agent.find_oldest_unsummarized_pair(),
Some((3, 4)),
"pruned pair should be skipped"
);
}
#[tokio::test]
async fn fetch_graph_facts_returns_some_with_entities_and_has_prefix() {
use zeph_memory::graph::{EntityType, GraphStore};
let memory = build_graph_memory().await;
let cid = memory.sqlite().create_conversation().await.unwrap();
{
let store = GraphStore::new(memory.sqlite().pool().clone());
let rust_id = store
.upsert_entity(
"rust",
"rust",
EntityType::Language,
Some("systems language"),
)
.await
.unwrap();
let tokio_id = store
.upsert_entity("tokio", "tokio", EntityType::Tool, Some("async runtime"))
.await
.unwrap();
store
.insert_edge(rust_id, tokio_id, "uses", "Rust uses tokio", 0.9, None)
.await
.unwrap();
}
let mem_state = make_mem_state(std::sync::Arc::new(memory), cid, true);
let tc = std::sync::Arc::new(zeph_memory::TokenCounter::new());
let result = Agent::<MockChannel>::fetch_graph_facts(&mem_state, "rust", 2000, &tc)
.await
.unwrap();
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.content.starts_with(super::super::GRAPH_FACTS_PREFIX));
}
#[cfg(feature = "lsp-context")]
#[test]
fn remove_lsp_messages_removes_lsp_system_keeps_others() {
use crate::agent::LSP_NOTE_PREFIX;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.push_message(Message {
role: Role::System,
content: "[recall] some recall data".to_owned(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.push_message(Message {
role: Role::System,
content: format!("{LSP_NOTE_PREFIX}diagnostics]\nsrc/main.rs:1 error: foo"),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.push_message(Message {
role: Role::User,
content: "hello".to_owned(),
parts: vec![],
metadata: MessageMetadata::default(),
});
let before = agent.messages.len();
agent.remove_lsp_messages();
assert_eq!(agent.messages.len(), before - 1);
assert!(
agent
.messages
.iter()
.all(|m| !m.content.starts_with(LSP_NOTE_PREFIX))
);
assert!(
agent
.messages
.iter()
.any(|m| m.content.starts_with("[recall]"))
);
}
#[tokio::test]
async fn cooldown_guard_decrements_and_skips_compaction() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_compaction_cooldown(2)
.with_metrics(tx);
agent.context_manager.compaction_turns_since = 2;
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} padding to exceed budget threshold"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.maybe_compact().await.unwrap();
assert_eq!(agent.context_manager.compaction_turns_since, 1);
assert_eq!(rx.borrow().context_compactions, 0);
agent.maybe_compact().await.unwrap();
assert_eq!(agent.context_manager.compaction_turns_since, 0);
assert_eq!(rx.borrow().context_compactions, 0);
}
#[tokio::test]
async fn cooldown_guard_fires_after_expiry_and_resets_counter() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_compaction_cooldown(2)
.with_metrics(tx);
agent.context_manager.compaction_turns_since = 0;
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} padding to exceed budget threshold"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.providers.cached_prompt_tokens = 10_000;
agent.maybe_compact().await.unwrap();
assert_eq!(rx.borrow().context_compactions, 1);
assert!(agent.context_manager.compaction_exhausted);
}
#[tokio::test]
async fn exhaustion_guard_skips_compaction_when_exhausted() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_metrics(tx);
agent.context_manager.compaction_exhausted = true;
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} padding to exceed budget threshold"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.maybe_compact().await.unwrap();
assert_eq!(rx.borrow().context_compactions, 0);
}
#[tokio::test]
async fn exhaustion_guard_warned_flag_set_once() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0);
agent.context_manager.compaction_exhausted = true;
for i in 0..5 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i}"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
assert!(!agent.context_manager.exhaustion_warned);
agent.maybe_compact().await.unwrap();
assert!(agent.context_manager.exhaustion_warned);
agent.maybe_compact().await.unwrap();
assert!(agent.context_manager.exhaustion_warned);
}
#[tokio::test]
async fn exhaustion_guard_takes_precedence_over_cooldown() {
use std::sync::Arc;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let statuses = Arc::clone(&channel.statuses);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 2, 0)
.with_compaction_cooldown(2);
agent.context_manager.compaction_exhausted = true;
agent.context_manager.compaction_turns_since = 2;
for i in 0..10 {
agent.messages.push(Message {
role: Role::User,
content: format!("message {i} padding to exceed budget threshold"),
parts: vec![],
metadata: MessageMetadata::default(),
});
}
agent.maybe_compact().await.unwrap();
assert_eq!(agent.context_manager.compaction_turns_since, 2);
assert!(
!statuses
.lock()
.unwrap()
.iter()
.any(|s| s == "compacting context..."),
"compaction must not have started"
);
}
#[tokio::test]
async fn counterproductive_guard_sets_exhausted_when_too_few_messages() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100, 0.20, 0.75, 5, 0);
agent.messages.push(Message {
role: Role::User,
content: "x".repeat(200),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.messages.push(Message {
role: Role::User,
content: "x".repeat(200),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.maybe_compact().await.unwrap();
assert!(agent.context_manager.compaction_exhausted);
}
#[test]
fn context_manager_defaults_have_compaction_guard_fields() {
let cm = crate::agent::context_manager::ContextManager::new();
assert_eq!(cm.compaction_cooldown_turns, 2);
assert_eq!(cm.compaction_turns_since, 0);
assert!(!cm.compaction_exhausted);
assert!(!cm.exhaustion_warned);
}
#[test]
fn builder_with_compaction_cooldown_sets_field() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let agent =
Agent::new(provider, channel, registry, None, 5, executor).with_compaction_cooldown(5);
assert_eq!(agent.context_manager.compaction_cooldown_turns, 5);
}
#[test]
fn compaction_hard_count_zero_by_default() {
let snapshot = crate::metrics::MetricsSnapshot::default();
assert_eq!(snapshot.compaction_hard_count, 0);
assert!(snapshot.compaction_turns_after_hard.is_empty());
}
#[tokio::test]
async fn compaction_hard_count_increments_on_hard_tier() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_metrics(tx);
agent.providers.cached_prompt_tokens = 900;
agent.maybe_compact().await.unwrap();
assert_eq!(rx.borrow().compaction_hard_count, 1);
}
#[tokio::test]
async fn compaction_turns_after_hard_tracks_segments() {
let provider = mock_provider(vec!["summary".to_string()]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let (tx, rx) = watch::channel(crate::metrics::MetricsSnapshot::default());
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0)
.with_compaction_cooldown(0)
.with_metrics(tx);
agent.providers.cached_prompt_tokens = 900;
agent.maybe_compact().await.unwrap();
assert_eq!(rx.borrow().compaction_hard_count, 1);
agent.providers.cached_prompt_tokens = 0;
for _ in 0..3 {
agent.context_manager.compacted_this_turn = false;
agent.maybe_compact().await.unwrap();
}
if let Some(turns) = agent.context_manager.turns_since_last_hard_compaction {
agent.update_metrics(|m| {
m.compaction_turns_after_hard.push(turns);
m.compaction_hard_count += 1;
});
agent.context_manager.turns_since_last_hard_compaction = Some(0);
}
assert_eq!(rx.borrow().compaction_hard_count, 2);
assert_eq!(rx.borrow().compaction_turns_after_hard, vec![3]);
}
#[tokio::test]
async fn compaction_turn_counter_increments_before_exhaustion_guard() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(1000, 0.20, 0.75, 4, 0);
agent.context_manager.turns_since_last_hard_compaction = Some(0);
agent.context_manager.compaction_exhausted = true;
agent.maybe_compact().await.unwrap();
assert_eq!(
agent.context_manager.turns_since_last_hard_compaction,
Some(1)
);
}
#[test]
fn mid_iteration_skips_when_compacted_this_turn() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.90, 4, 0)
.with_soft_compaction_threshold(0.60);
make_tool_pair_with_output(&mut agent, "a");
agent.messages[2].metadata.deferred_summary = Some("sum_a".into());
agent.providers.cached_prompt_tokens = 75_000;
agent.context_manager.compacted_this_turn = true;
agent.maybe_soft_compact_mid_iteration();
let applied = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(
!applied,
"must not apply deferred summaries when compacted_this_turn is set"
);
}
#[test]
fn mid_iteration_skips_when_tier_is_none() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.90, 4, 0)
.with_soft_compaction_threshold(0.60);
make_tool_pair_with_output(&mut agent, "a");
agent.messages[2].metadata.deferred_summary = Some("sum_a".into());
agent.providers.cached_prompt_tokens = 50_000;
agent.maybe_soft_compact_mid_iteration();
let applied = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(!applied, "must not compact when tier is None");
}
#[test]
fn mid_iteration_applies_deferred_summaries_at_soft_tier() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.90, 4, 0)
.with_soft_compaction_threshold(0.60);
make_tool_pair_with_output(&mut agent, "a");
agent.messages[2].metadata.deferred_summary = Some("sum_a".into());
agent.providers.cached_prompt_tokens = 75_000;
agent.maybe_soft_compact_mid_iteration();
let summary_inserted = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(
summary_inserted,
"deferred summary must be applied at soft tier"
);
}
#[test]
fn mid_iteration_does_not_set_compacted_this_turn() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.90, 4, 0)
.with_soft_compaction_threshold(0.60);
make_tool_pair_with_output(&mut agent, "a");
agent.providers.cached_prompt_tokens = 75_000;
assert!(!agent.context_manager.compacted_this_turn);
agent.maybe_soft_compact_mid_iteration();
assert!(
!agent.context_manager.compacted_this_turn,
"maybe_soft_compact_mid_iteration must not set compacted_this_turn"
);
}
#[test]
fn mid_iteration_fires_at_hard_tier() {
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = create_test_registry();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor)
.with_context_budget(100_000, 0.20, 0.90, 4, 0)
.with_soft_compaction_threshold(0.60);
make_tool_pair_with_output(&mut agent, "a");
agent.messages[2].metadata.deferred_summary = Some("sum_a".into());
agent.providers.cached_prompt_tokens = 95_000;
agent.maybe_soft_compact_mid_iteration();
let summary_inserted = agent.messages.iter().any(|m| {
m.parts
.iter()
.any(|p| matches!(p, MessagePart::Summary { .. }))
});
assert!(
summary_inserted,
"deferred summaries must be applied even when tier is Hard"
);
assert!(
!agent.context_manager.compacted_this_turn,
"mid-iteration must not set compacted_this_turn even at Hard tier"
);
}
#[tokio::test]
async fn clear_history_retains_system_prompt() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.messages.push(Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
agent.messages.push(Message {
role: Role::Assistant,
content: "world".into(),
parts: vec![],
metadata: MessageMetadata::default(),
});
assert_eq!(agent.messages.len(), 3);
agent.clear_history();
assert_eq!(
agent.messages.len(),
1,
"clear_history must leave exactly the system prompt"
);
assert_eq!(
agent.messages[0].role,
Role::System,
"retained message must be the system prompt"
);
}
#[tokio::test]
async fn clear_history_with_only_system_prompt_is_idempotent() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
let system_content = agent.messages[0].content.clone();
agent.clear_history();
assert_eq!(agent.messages.len(), 1);
assert_eq!(
agent.messages[0].content, system_content,
"system prompt content must be unchanged after clear_history"
);
}
#[tokio::test]
async fn rebuild_system_prompt_empty_skill_list_does_not_crash() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent
.rebuild_system_prompt("test query with no skills")
.await;
let prompt = &agent.messages[0];
assert_eq!(
prompt.role,
Role::System,
"first message must still be the system prompt"
);
assert!(
!prompt.content.is_empty(),
"system prompt must be non-empty even with no skills"
);
}
#[tokio::test]
async fn rebuild_system_prompt_cache_markers_count() {
use zeph_skills::registry::SkillRegistry;
let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();
let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
agent.rebuild_system_prompt("test query").await;
let prompt = &agent.messages[0].content;
let stable_count = prompt.matches("<!-- cache:stable -->").count();
let volatile_count = prompt.matches("<!-- cache:volatile -->").count();
assert_eq!(
stable_count, 1,
"exactly one cache:stable marker must be present"
);
assert_eq!(
volatile_count, 1,
"exactly one cache:volatile marker must be present"
);
let total = stable_count + volatile_count + prompt.matches("<!-- cache:tools -->").count();
assert!(
total <= 4,
"total cache markers must not exceed 4 (Claude API limit); got {total}"
);
}
}