use serde_json::json;
use zeph_llm::provider::{Message, MessagePart, Role};
use crate::agent::{estimate_parts_size, trim_parent_messages};
fn text_msg(role: Role, text: &str) -> Message {
Message::from_parts(
role,
vec![MessagePart::Text {
text: text.to_owned(),
}],
)
}
fn tool_use_msg(id: &str, name: &str) -> Message {
Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: id.to_owned(),
name: name.to_owned(),
input: json!({}),
}],
)
}
fn tool_result_msg(tool_use_id: &str, content: &str) -> Message {
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: tool_use_id.to_owned(),
content: content.to_owned(),
is_error: false,
}],
)
}
#[test]
fn trim_parent_messages_drops_orphaned_tool_results() {
let mut msgs = vec![
tool_result_msg("tu_A", "result-a"),
text_msg(Role::Assistant, "ok"),
];
trim_parent_messages(&mut msgs, usize::MAX);
assert_eq!(msgs.len(), 1, "orphaned ToolResult message must be removed");
assert!(
msgs[0]
.parts
.iter()
.all(|p| !matches!(p, MessagePart::ToolResult { .. })),
"no ToolResult parts must remain"
);
}
#[test]
fn trim_parent_messages_keeps_matched_tool_pairs() {
let mut msgs = vec![
tool_use_msg("tu_B", "shell"),
tool_result_msg("tu_B", "output-b"),
];
trim_parent_messages(&mut msgs, usize::MAX);
assert_eq!(msgs.len(), 2, "matched pair must not be removed");
let has_use = msgs[0]
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolUse { id, .. } if id == "tu_B"));
let has_result = msgs[1]
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolResult { tool_use_id, .. } if tool_use_id == "tu_B"));
assert!(has_use, "ToolUse must be preserved");
assert!(has_result, "ToolResult must be preserved");
}
#[test]
fn trim_parent_messages_budget_uses_structured_size() {
let large_input = json!({"cmd": "x".repeat(200)});
let assistant_msg = Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "tu_x".to_owned(),
name: "shell".to_owned(),
input: large_input,
}],
);
let estimated = estimate_parts_size(&assistant_msg);
assert!(
estimated > assistant_msg.content.len(),
"structured size ({estimated}) must exceed flat content ({})",
assistant_msg.content.len()
);
let mut msgs = vec![assistant_msg, text_msg(Role::User, "hi")];
trim_parent_messages(&mut msgs, 10); assert!(
msgs.len() < 2,
"budget truncation must fire based on structured size"
);
}
#[test]
fn trim_parent_messages_removes_empty_message_after_pruning() {
let mut msgs = vec![
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "tu_orphan".to_owned(),
content: "result".to_owned(),
is_error: false,
}],
),
text_msg(Role::Assistant, "reply"),
];
trim_parent_messages(&mut msgs, usize::MAX);
assert!(
msgs.iter()
.all(|m| m.role != Role::User || !m.parts.is_empty()),
"emptied user messages must be removed"
);
let has_orphan = msgs.iter().flat_map(|m| m.parts.iter()).any(
|p| matches!(p, MessagePart::ToolResult { tool_use_id, .. } if tool_use_id == "tu_orphan"),
);
assert!(!has_orphan, "orphaned ToolResult must not survive");
}
#[test]
fn orphan_pruning_preserves_thinking_block() {
let thinking_text = "deep reasoning here";
let assistant_msg = Message::from_parts(
Role::Assistant,
vec![
MessagePart::ThinkingBlock {
thinking: thinking_text.to_owned(),
signature: "sig123".to_owned(),
},
MessagePart::Text {
text: "answer".to_owned(),
},
MessagePart::ToolUse {
id: "tu_matched".to_owned(),
name: "shell".to_owned(),
input: json!({}),
},
],
);
let content_before = assistant_msg.content.clone();
let mut msgs = vec![
assistant_msg,
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "tu_matched".to_owned(),
content: "ok".to_owned(),
is_error: false,
}],
),
];
trim_parent_messages(&mut msgs, usize::MAX);
assert_eq!(msgs.len(), 2, "no messages should be removed");
assert_eq!(msgs[0].parts.len(), 3, "all 3 assistant parts must survive");
assert_eq!(
msgs[0].content, content_before,
"content must not be modified (ThinkingBlock must not be erased)"
);
}
#[test]
fn trailing_assistant_tool_use_preserved_without_result() {
let mut msgs = vec![
text_msg(Role::User, "do something"),
tool_use_msg("tu_trailing", "shell"),
];
trim_parent_messages(&mut msgs, usize::MAX);
assert_eq!(msgs.len(), 2, "both messages must be preserved");
let has_trailing_use = msgs[1]
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolUse { id, .. } if id == "tu_trailing"));
assert!(
has_trailing_use,
"trailing unanswered ToolUse must not be pruned"
);
}
#[test]
fn budget_keeps_suffix_not_prefix() {
let small = text_msg(Role::User, "recent"); let large = text_msg(Role::User, "x".repeat(500).as_str()); let small_size = estimate_parts_size(&small);
let large_size = estimate_parts_size(&large);
let budget = small_size + large_size / 2; let mut msgs = vec![large, small]; trim_parent_messages(&mut msgs, budget);
assert_eq!(msgs.len(), 1, "only one message must fit");
assert_eq!(
msgs[0].content, "recent",
"the most recent (suffix) message must be kept, not the older one"
);
}
#[test]
fn trim_parent_messages_partial_prune_keeps_text() {
let mut msgs = vec![
text_msg(Role::Assistant, "thinking..."),
Message::from_parts(
Role::User,
vec![
MessagePart::ToolResult {
tool_use_id: "tu_gone".to_owned(),
content: "old result".to_owned(),
is_error: false,
},
MessagePart::Text {
text: "also some user text".to_owned(),
},
],
),
text_msg(Role::Assistant, "ok"),
];
trim_parent_messages(&mut msgs, usize::MAX);
let user_msg = msgs.iter().find(|m| m.role == Role::User);
assert!(
user_msg.is_some(),
"user message must survive partial pruning"
);
let user_msg = user_msg.unwrap();
let has_orphan = user_msg
.parts
.iter()
.any(|p| matches!(p, MessagePart::ToolResult { .. }));
assert!(!has_orphan, "orphaned ToolResult must be removed");
let has_text = user_msg
.parts
.iter()
.any(|p| matches!(p, MessagePart::Text { text } if text == "also some user text"));
assert!(has_text, "Text part must survive after orphan removal");
}