use std::sync::Arc;
#[cfg(test)]
use zeph_llm::provider::{Message, MessagePart, Role};
use zeph_memory::AnchoredSummary;
use super::super::Agent;
use crate::channel::Channel;
use zeph_context::summarization::SummarizationDeps;
impl<C: Channel> Agent<C> {
pub(super) fn build_summarization_deps(&self) -> SummarizationDeps {
let debug_dumper = self.runtime.debug.debug_dumper.clone();
let token_counter = Arc::clone(&self.runtime.metrics.token_counter);
#[allow(clippy::type_complexity)]
let on_anchored_summary: Option<Arc<dyn Fn(&AnchoredSummary, bool) + Send + Sync>> =
debug_dumper.map(|d| {
let tc = Arc::clone(&self.runtime.metrics.token_counter);
#[allow(clippy::type_complexity)]
let cb: Arc<dyn Fn(&AnchoredSummary, bool) + Send + Sync> =
Arc::new(move |summary: &AnchoredSummary, fallback: bool| {
d.dump_anchored_summary(summary, fallback, &tc);
});
cb
});
SummarizationDeps {
provider: self.summary_or_primary_provider().clone(),
llm_timeout: std::time::Duration::from_secs(self.runtime.config.timeouts.llm_seconds),
token_counter,
structured_summaries: self.services.memory.compaction.structured_summaries,
on_anchored_summary,
}
}
async fn load_compression_guidelines(
enabled: bool,
memory: Option<std::sync::Arc<zeph_memory::semantic::SemanticMemory>>,
conv_id: Option<zeph_memory::ConversationId>,
) -> String {
if !enabled {
return String::new();
}
let Some(memory) = memory else {
return String::new();
};
let sqlite = memory.sqlite().clone();
match sqlite.load_compression_guidelines(conv_id).await {
Ok((_, text)) => text,
Err(e) => {
tracing::warn!("failed to load compression guidelines: {e:#}");
String::new()
}
}
}
#[cfg(test)]
fn adjust_compact_end_for_tool_pairs(messages: &[Message], compact_end: usize) -> usize {
let mut end = compact_end;
loop {
if end <= 1 {
return 1;
}
if end >= messages.len() {
break;
}
let first_tail = &messages[end];
let is_tool_result_msg = first_tail.role == Role::User
&& !first_tail.parts.is_empty()
&& first_tail
.parts
.iter()
.all(|p| matches!(p, MessagePart::ToolResult { .. }));
if !is_tool_result_msg {
break;
}
end -= 1;
}
if end < compact_end && end > 1 {
let preceding = &messages[end - 1];
let is_tool_use_msg = preceding.role == Role::Assistant
&& preceding
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolUse { .. }));
if is_tool_use_msg {
end -= 1;
}
}
end.max(1)
}
}
pub(in crate::agent) mod adapters;
mod compaction;
mod deferred;
mod pruning;
mod scheduling;
#[cfg(test)]
impl<C: Channel> Agent<C> {
pub(in crate::agent::context) fn build_metadata_summary(messages: &[Message]) -> String {
zeph_context::summarization::build_metadata_summary(messages, |s, n| {
super::truncate_chars(s, n)
})
}
pub(in crate::agent::context) fn remove_tool_responses_middle_out(
messages: Vec<Message>,
fraction: f32,
) -> Vec<Message> {
zeph_context::summarization::remove_tool_responses_middle_out(messages, fraction)
}
}
#[cfg(test)]
mod tests {
use super::*;
use zeph_context::summarization::extract_overflow_ref;
#[test]
fn extract_overflow_ref_returns_uuid_when_present() {
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let body = format!(
"some output\n[full output stored \u{2014} ID: {uuid} \u{2014} 12345 bytes, use read_overflow tool to retrieve]"
);
assert_eq!(extract_overflow_ref(&body), Some(uuid));
}
#[test]
fn extract_overflow_ref_returns_none_when_absent() {
let body = "normal small output without overflow notice";
assert_eq!(extract_overflow_ref(body), None);
}
#[test]
fn extract_overflow_ref_returns_none_for_empty_body() {
assert_eq!(extract_overflow_ref(""), None);
}
#[test]
fn extract_overflow_ref_handles_notice_at_start() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let body = format!(
"[full output stored \u{2014} ID: {uuid} \u{2014} 9999 bytes, use read_overflow tool to retrieve]"
);
assert_eq!(extract_overflow_ref(&body), Some(uuid));
}
#[test]
fn prune_tool_outputs_skips_focus_pinned_messages() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.prune_protect_tokens = 0;
let mut pinned_meta = MessageMetadata::focus_pinned();
pinned_meta.focus_pinned = true;
let big_body = "x".repeat(5000);
let mut pinned_msg = Message {
role: Role::System,
content: big_body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: big_body.clone(),
compacted_at: None,
}],
metadata: pinned_meta,
};
pinned_msg.rebuild_content();
agent.msg.messages.push(pinned_msg);
let big_body2 = "y".repeat(5000);
let mut normal_msg = Message {
role: Role::User,
content: big_body2.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "shell".into(),
body: big_body2.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
normal_msg.rebuild_content();
agent.msg.messages.push(normal_msg);
let freed = agent.prune_tool_outputs(1);
let pinned = &agent.msg.messages[1];
if let MessagePart::ToolOutput {
body, compacted_at, ..
} = &pinned.parts[0]
{
assert_eq!(*body, "x".repeat(5000), "pinned body must not be evicted");
assert!(
compacted_at.is_none(),
"pinned compacted_at must remain None"
);
}
let normal = &agent.msg.messages[2];
if let MessagePart::ToolOutput { compacted_at, .. } = &normal.parts[0] {
assert!(compacted_at.is_some(), "non-pinned body must be evicted");
}
assert!(freed > 0, "must free tokens from non-pinned message");
}
#[test]
fn prune_tool_outputs_oldest_first_evicts_from_front() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.prune_protect_tokens = 0;
for i in 0..3 {
let body = format!("tool output {i} {}", "z".repeat(500));
let mut msg = Message {
role: Role::User,
content: body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "shell".into(),
body: body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
msg.rebuild_content();
agent.msg.messages.push(msg);
}
agent.prune_tool_outputs_oldest_first(1);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[1].parts[0] {
assert!(
compacted_at.is_some(),
"oldest tool output must be evicted first"
);
}
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[2].parts[0] {
assert!(
compacted_at.is_none(),
"second tool output must still be intact"
);
}
}
#[test]
fn build_anchored_summary_prompt_contains_required_fields_and_history() {
use zeph_llm::provider::{Message, MessageMetadata, Role};
let messages = vec![
Message {
role: Role::User,
content: "refactor the auth middleware".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "I will split it into two modules".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let prompt = zeph_context::summarization::build_anchored_summary_prompt(&messages, "");
assert!(prompt.contains("session_intent"), "missing session_intent");
assert!(prompt.contains("files_modified"), "missing files_modified");
assert!(prompt.contains("decisions_made"), "missing decisions_made");
assert!(prompt.contains("open_questions"), "missing open_questions");
assert!(prompt.contains("next_steps"), "missing next_steps");
assert!(
prompt.contains("refactor the auth middleware"),
"user message not in prompt"
);
assert!(
prompt.contains("I will split it into two modules"),
"assistant message not in prompt"
);
}
#[test]
fn build_anchored_summary_prompt_includes_guidelines() {
use zeph_llm::provider::{Message, MessageMetadata, Role};
let messages = vec![Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let prompt = zeph_context::summarization::build_anchored_summary_prompt(
&messages,
"focus on file paths",
);
assert!(
prompt.contains("compression-guidelines"),
"guidelines section missing"
);
assert!(
prompt.contains("focus on file paths"),
"guidelines content missing"
);
}
#[test]
fn dump_anchored_summary_creates_file_with_required_fields() {
use crate::debug_dump::{DebugDumper, DumpFormat};
use zeph_memory::{AnchoredSummary, TokenCounter};
let dir = tempfile::tempdir().expect("tempdir");
let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).expect("dumper creation");
let summary = AnchoredSummary {
session_intent: "Test dump".into(),
files_modified: vec!["a.rs".into(), "b.rs".into()],
decisions_made: vec!["Decision: async — Reason: performance".into()],
open_questions: vec![],
next_steps: vec!["Run tests".into()],
};
let counter = TokenCounter::new();
dumper.dump_anchored_summary(&summary, false, &counter);
let entries: Vec<_> = std::fs::read_dir(dumper.dir())
.expect("read_dir")
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("-anchored-summary.json"))
})
.collect();
assert_eq!(
entries.len(),
1,
"exactly one anchored-summary.json expected"
);
let content = std::fs::read_to_string(&entries[0]).expect("read file");
let v: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
assert!(
v.get("section_completeness").is_some(),
"missing section_completeness"
);
assert!(v.get("total_items").is_some(), "missing total_items");
assert!(v.get("token_estimate").is_some(), "missing token_estimate");
assert!(v.get("fallback").is_some(), "missing fallback field");
assert_eq!(v["fallback"], false, "fallback must be false");
let sc = &v["section_completeness"];
assert_eq!(sc["session_intent"], true);
assert_eq!(sc["files_modified"], true);
assert_eq!(sc["decisions_made"], true);
assert_eq!(sc["open_questions"], false);
assert_eq!(sc["next_steps"], true);
}
#[test]
fn dump_anchored_summary_fallback_flag_propagated() {
use crate::debug_dump::{DebugDumper, DumpFormat};
use zeph_memory::{AnchoredSummary, TokenCounter};
let dir = tempfile::tempdir().expect("tempdir");
let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).expect("dumper creation");
let empty = AnchoredSummary {
session_intent: String::new(),
files_modified: vec![],
decisions_made: vec![],
open_questions: vec![],
next_steps: vec![],
};
let counter = TokenCounter::new();
dumper.dump_anchored_summary(&empty, true, &counter);
let entries: Vec<_> = std::fs::read_dir(dumper.dir())
.expect("read_dir")
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("-anchored-summary.json"))
})
.collect();
assert_eq!(
entries.len(),
1,
"exactly one anchored-summary.json expected"
);
let content = std::fs::read_to_string(&entries[0]).expect("read file");
let v: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
assert_eq!(v["fallback"], true, "fallback flag must be true");
assert_eq!(
v["total_items"], 0,
"total_items must be 0 for empty summary"
);
}
#[test]
fn prune_tool_outputs_scored_evicts_lowest_relevance_first() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use crate::config::PruningStrategy;
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compression.pruning_strategy = PruningStrategy::TaskAware;
agent.services.compression.current_task_goal =
Some("authentication middleware session token".to_string());
agent.context_manager.prune_protect_tokens = 0;
let rel_body = "authentication middleware session token implementation ".repeat(50);
let mut rel_msg = Message {
role: Role::User,
content: rel_body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: rel_body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
rel_msg.rebuild_content();
agent.msg.messages.push(rel_msg);
let irrel_body = "database migration schema table column index ".repeat(50);
let mut irrel_msg = Message {
role: Role::User,
content: irrel_body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: irrel_body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
irrel_msg.rebuild_content();
agent.msg.messages.push(irrel_msg);
agent.prune_tool_outputs_scored(1);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[2].parts[0] {
assert!(
compacted_at.is_some(),
"low-relevance block must be evicted"
);
}
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[1].parts[0] {
assert!(compacted_at.is_none(), "high-relevance block must survive");
}
}
#[test]
fn prune_tool_outputs_mig_evicts_lowest_mig_first() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use crate::config::PruningStrategy;
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compression.pruning_strategy = PruningStrategy::Mig;
agent.services.compression.current_task_goal = Some("authentication token".to_string());
agent.context_manager.prune_protect_tokens = 0;
let rel_body = "authentication token session middleware ".repeat(50);
let mut rel_msg = Message {
role: Role::User,
content: rel_body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: rel_body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
rel_msg.rebuild_content();
agent.msg.messages.push(rel_msg);
let irrel_body = "database schema table column index ".repeat(50);
let mut irrel_msg = Message {
role: Role::User,
content: irrel_body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: irrel_body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
irrel_msg.rebuild_content();
agent.msg.messages.push(irrel_msg);
agent.prune_tool_outputs_mig(1);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[2].parts[0] {
assert!(
compacted_at.is_some(),
"low-MIG (irrelevant) block must be evicted"
);
} else {
panic!("expected ToolOutput at messages[2]");
}
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[1].parts[0] {
assert!(
compacted_at.is_none(),
"high-MIG (relevant) block must survive"
);
} else {
panic!("expected ToolOutput at messages[1]");
}
}
#[test]
fn prune_tool_outputs_scored_respects_protect_tokens() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use crate::config::PruningStrategy;
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compression.pruning_strategy = PruningStrategy::TaskAware;
agent.services.compression.current_task_goal = Some("irrelevant goal".to_string());
agent.context_manager.prune_protect_tokens = 999_999;
let body = "unrelated content database schema ".repeat(50);
let mut msg = Message {
role: Role::User,
content: body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
msg.rebuild_content();
agent.msg.messages.push(msg);
let freed = agent.prune_tool_outputs_scored(1);
assert_eq!(
freed, 0,
"no tokens should be freed when everything is protected"
);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[1].parts[0] {
assert!(
compacted_at.is_none(),
"protected block must not be evicted"
);
} else {
panic!("expected ToolOutput at messages[1]");
}
}
#[test]
fn prune_tool_outputs_mig_respects_protect_tokens() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use crate::config::PruningStrategy;
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compression.pruning_strategy = PruningStrategy::Mig;
agent.services.compression.current_task_goal = Some("irrelevant goal".to_string());
agent.context_manager.prune_protect_tokens = 999_999;
let body = "unrelated content database schema ".repeat(50);
let mut msg = Message {
role: Role::User,
content: body.clone(),
parts: vec![MessagePart::ToolOutput {
tool_name: "read".into(),
body: body.clone(),
compacted_at: None,
}],
metadata: MessageMetadata::default(),
};
msg.rebuild_content();
agent.msg.messages.push(msg);
let freed = agent.prune_tool_outputs_mig(1);
assert_eq!(
freed, 0,
"no tokens should be freed when everything is protected"
);
if let MessagePart::ToolOutput { compacted_at, .. } = &agent.msg.messages[1].parts[0] {
assert!(
compacted_at.is_none(),
"protected block must not be evicted"
);
} else {
panic!("expected ToolOutput at messages[1]");
}
}
}
#[cfg(test)]
mod subgoal_extraction_tests {
use crate::agent::context::summarization::scheduling::parse_subgoal_extraction_response;
#[test]
fn parse_well_formed_with_both() {
let response = "CURRENT: Implement login\nCOMPLETED: Setup database";
let result = parse_subgoal_extraction_response(response);
assert_eq!(result.current, "Implement login");
assert_eq!(result.completed, Some("Setup database".to_string()));
}
#[test]
fn parse_well_formed_no_completed() {
let response = "CURRENT: Fetch user data\nCOMPLETED: NONE";
let result = parse_subgoal_extraction_response(response);
assert_eq!(result.current, "Fetch user data");
assert_eq!(result.completed, None);
}
#[test]
fn parse_malformed_no_current_prefix() {
let response = "Just some random text about subgoals";
let result = parse_subgoal_extraction_response(response);
assert_eq!(result.current, "Just some random text about subgoals");
assert_eq!(result.completed, None);
}
#[test]
fn parse_malformed_empty_current() {
let response = "CURRENT: \nCOMPLETED: Setup";
let result = parse_subgoal_extraction_response(response);
assert_eq!(result.current.trim(), "CURRENT: \nCOMPLETED: Setup");
assert_eq!(result.completed, None);
}
}
#[cfg(test)]
mod orphan_tool_result_tests {
use super::super::super::Agent;
#[tokio::test]
async fn compact_context_with_budget_no_orphan_tool_result() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec!["SUMMARY".to_string()]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compaction_preserve_tail = 1;
agent.services.memory.compaction.structured_summaries = false;
agent.msg.messages.push(Message {
role: Role::User,
content: "hello".into(),
parts: vec![MessagePart::Text {
text: "hello".into(),
}],
metadata: MessageMetadata::default(),
});
agent.msg.messages.push(Message {
role: Role::Assistant,
content: "hi".into(),
parts: vec![MessagePart::Text { text: "hi".into() }],
metadata: MessageMetadata::default(),
});
agent.msg.messages.push(Message {
role: Role::User,
content: "ask".into(),
parts: vec![MessagePart::Text { text: "ask".into() }],
metadata: MessageMetadata::default(),
});
agent.msg.messages.push(Message {
role: Role::Assistant,
content: String::new(),
parts: vec![MessagePart::ToolUse {
id: "t1".into(),
name: "shell".into(),
input: serde_json::json!({}),
}],
metadata: MessageMetadata::default(),
});
agent.msg.messages.push(Message {
role: Role::User,
content: String::new(),
parts: vec![MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: "result".into(),
is_error: false,
}],
metadata: MessageMetadata::default(),
});
assert_eq!(agent.msg.messages.len(), 6, "precondition: 6 messages");
let result = agent.compact_context_with_budget(None).await;
assert!(
result.is_ok(),
"compact_context_with_budget must succeed: {result:?}"
);
assert_eq!(
agent.msg.messages.len(),
4,
"should compact 3 msgs + insert summary"
);
assert_eq!(agent.msg.messages[1].role, Role::System, "summary at idx 1");
assert!(
agent.msg.messages[1]
.content
.starts_with("[conversation summary —"),
"summary marker: {:?}",
&agent.msg.messages[1].content[..agent.msg.messages[1].content.len().min(60)]
);
assert_no_orphan_tool_results(&agent.msg.messages);
}
#[tokio::test]
async fn compact_context_hard_compaction_no_orphan_tool_result_e2e() {
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
let mut agent = Agent::new(
mock_provider(vec!["SUMMARY".to_string()]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent.context_manager.compaction_preserve_tail = 1;
agent.services.memory.compaction.structured_summaries = false;
let text = |role: Role, s: &str| Message {
role,
content: s.into(),
parts: vec![MessagePart::Text { text: s.into() }],
metadata: MessageMetadata::default(),
};
let tool_use = |id: &str| Message {
role: Role::Assistant,
content: String::new(),
parts: vec![MessagePart::ToolUse {
id: id.into(),
name: "shell".into(),
input: serde_json::json!({}),
}],
metadata: MessageMetadata::default(),
};
let tool_result = |id: &str| Message {
role: Role::User,
content: String::new(),
parts: vec![MessagePart::ToolResult {
tool_use_id: id.into(),
content: "ok".into(),
is_error: false,
}],
metadata: MessageMetadata::default(),
};
agent.msg.messages.push(text(Role::User, "turn 1"));
agent.msg.messages.push(text(Role::Assistant, "reply 1"));
agent.msg.messages.push(text(Role::User, "turn 2"));
agent.msg.messages.push(tool_use("t1"));
agent.msg.messages.push(tool_result("t1"));
agent.msg.messages.push(text(Role::User, "turn 3"));
agent.msg.messages.push(text(Role::Assistant, "reply 3"));
agent.msg.messages.push(text(Role::User, "turn 4"));
agent.msg.messages.push(tool_use("t_last"));
agent.msg.messages.push(tool_result("t_last"));
assert_eq!(agent.msg.messages.len(), 11, "precondition: 11 messages");
let result = agent.compact_context_with_budget(None).await;
assert!(
result.is_ok(),
"compact_context_with_budget must succeed: {result:?}"
);
assert_eq!(agent.msg.messages[1].role, Role::System, "summary at idx 1");
assert!(
agent.msg.messages[1]
.content
.starts_with("[conversation summary —"),
"summary marker: {:?}",
&agent.msg.messages[1].content[..agent.msg.messages[1].content.len().min(60)]
);
assert!(
agent.msg.messages.len() < 11,
"compaction must have reduced message count (got {})",
agent.msg.messages.len()
);
assert_no_orphan_tool_results(&agent.msg.messages);
}
fn assert_no_orphan_tool_results(messages: &[zeph_llm::provider::Message]) {
use zeph_llm::provider::MessagePart;
for (i, msg) in messages.iter().enumerate() {
for part in &msg.parts {
if let MessagePart::ToolResult { tool_use_id, .. } = part {
assert!(i > 0, "orphan ToolResult at idx 0: no preceding message");
let matched = messages[i - 1]
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolUse { id, .. } if id == tool_use_id));
assert!(
matched,
"orphan ToolResult at idx {i} (tool_use_id={tool_use_id:?}): \
preceding message has no matching ToolUse"
);
}
}
}
}
}
#[cfg(test)]
mod compact_end_tool_pair_tests {
use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
use super::super::super::Agent;
use crate::agent::tests::agent_tests::MockChannel;
fn tool_use_msg() -> Message {
Message {
role: Role::Assistant,
content: String::new(),
parts: vec![MessagePart::ToolUse {
id: "tu1".into(),
name: "shell".into(),
input: serde_json::json!({}),
}],
metadata: MessageMetadata::default(),
}
}
fn tool_result_msg() -> Message {
Message {
role: Role::User,
content: String::new(),
parts: vec![MessagePart::ToolResult {
tool_use_id: "tu1".into(),
content: "ok".into(),
is_error: false,
}],
metadata: MessageMetadata::default(),
}
}
fn text_msg(role: Role, text: &str) -> Message {
Message {
role,
content: text.into(),
parts: vec![MessagePart::Text { text: text.into() }],
metadata: MessageMetadata::default(),
}
}
fn system_msg() -> Message {
Message {
role: Role::System,
content: "system".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}
}
fn adjust(messages: &[Message], compact_end: usize) -> usize {
Agent::<MockChannel>::adjust_compact_end_for_tool_pairs(messages, compact_end)
}
#[test]
fn no_tool_pair_at_boundary_unchanged() {
let msgs = vec![
system_msg(),
text_msg(Role::User, "hi"),
text_msg(Role::Assistant, "ok"),
text_msg(Role::User, "bye"),
];
assert_eq!(adjust(&msgs, 3), 3);
}
#[test]
fn tool_result_at_boundary_absorbs_pair() {
let msgs = vec![
system_msg(),
text_msg(Role::User, "hi"),
tool_use_msg(),
tool_result_msg(),
text_msg(Role::User, "bye"),
];
assert_eq!(adjust(&msgs, 3), 2);
}
#[test]
fn multiple_tool_results_absorbed() {
let tool_result2 = Message {
role: Role::User,
content: String::new(),
parts: vec![MessagePart::ToolResult {
tool_use_id: "tu2".into(),
content: "ok2".into(),
is_error: false,
}],
metadata: MessageMetadata::default(),
};
let msgs = vec![
system_msg(),
text_msg(Role::User, "hi"),
tool_use_msg(),
tool_result_msg(),
tool_result2,
text_msg(Role::Assistant, "done"),
];
assert_eq!(adjust(&msgs, 4), 2);
}
#[test]
fn preserve_tail_zero_no_tool_result_unchanged() {
let msgs = vec![
system_msg(),
text_msg(Role::User, "hi"),
text_msg(Role::Assistant, "ok"),
];
assert_eq!(adjust(&msgs, 2), 2);
}
#[test]
fn compact_end_equals_len_does_not_panic() {
let msgs = vec![
system_msg(),
text_msg(Role::User, "hi"),
tool_use_msg(),
tool_result_msg(),
];
assert_eq!(adjust(&msgs, msgs.len()), msgs.len());
}
#[test]
fn compact_end_already_one_returns_one() {
let msgs = vec![system_msg(), tool_result_msg()];
assert_eq!(adjust(&msgs, 1), 1);
}
#[test]
fn only_tool_pairs_degenerate_returns_one() {
let msgs = vec![system_msg(), tool_use_msg(), tool_result_msg()];
assert_eq!(adjust(&msgs, 2), 1);
}
}