use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role, ToolUseRequest};
use crate::agent::Agent;
use crate::agent::tests::agent_tests::{
MockChannel, MockToolExecutor, create_test_registry, mock_provider,
};
fn make_agent() -> Agent<MockChannel> {
let mut agent = Agent::new(
mock_provider(vec![]),
MockChannel::new(vec![]),
create_test_registry(),
None,
5,
MockToolExecutor::no_tools(),
);
agent
.msg
.messages
.push(Message::from_legacy(Role::System, "system"));
agent
}
#[test]
fn select_messages_for_compression_returns_chronological_order() {
let mut agent = make_agent();
for i in 1..=10u32 {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
agent
.msg
.messages
.push(Message::from_legacy(role, format!("message {i}")));
}
let preserve_tail = 2;
let result = agent.select_messages_for_compression(preserve_tail);
let (_, to_compress) = result.expect("enough messages to compress");
let positions: Vec<u32> = to_compress
.iter()
.map(|m| {
m.content
.strip_prefix("message ")
.and_then(|s| s.parse::<u32>().ok())
.expect("message content must be 'message N'")
})
.collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(
positions, sorted,
"to_compress must be in ascending chronological order (regression for #4558); \
got: {positions:?}"
);
}
#[test]
fn select_messages_for_compression_excludes_pinned() {
let mut agent = make_agent();
for i in 1..=8u32 {
let mut msg = Message::from_legacy(Role::User, format!("msg {i}"));
if i == 3 || i == 5 {
msg.metadata.focus_pinned = true;
}
agent.msg.messages.push(msg);
}
let result = agent.select_messages_for_compression(1);
let (to_remove, to_compress) = result.expect("enough messages to compress");
let msg_at = |idx: usize| agent.msg.messages[idx].content.clone();
for idx in &to_remove {
let content = msg_at(*idx);
assert!(
!agent.msg.messages[*idx].metadata.focus_pinned,
"pinned message '{content}' must not be in to_remove"
);
}
for m in &to_compress {
assert!(
!m.metadata.focus_pinned,
"pinned message '{}' must not appear in to_compress",
m.content
);
}
}
fn tool_use_request(id: &str) -> ToolUseRequest {
ToolUseRequest {
id: id.to_owned(),
name: "bash".to_owned().into(),
input: serde_json::json!({}),
}
}
fn push_tool_result(agent: &mut Agent<MockChannel>, id: &str, content: &str, is_error: bool) {
let part = MessagePart::ToolResult {
tool_use_id: id.to_owned(),
content: content.to_owned(),
is_error,
};
agent.msg.messages.push(Message {
role: Role::User,
content: format!("[tool_result: {id}]\n{content}"),
parts: vec![part],
metadata: MessageMetadata::default(),
});
}
fn tool_result_count(agent: &Agent<MockChannel>, id: &str) -> usize {
agent
.msg
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter(|p| matches!(p, MessagePart::ToolResult { tool_use_id, .. } if tool_use_id == id))
.count()
}
#[tokio::test]
async fn persist_cancelled_tool_results_is_noop_when_all_ids_already_resolved() {
let mut agent = make_agent();
push_tool_result(&mut agent, "call-1", "real output", false);
let message_count_before = agent.msg.messages.len();
agent
.persist_cancelled_tool_results(&[tool_use_request("call-1")], None)
.await;
assert_eq!(
agent.msg.messages.len(),
message_count_before,
"no new message must be pushed when the id is already resolved"
);
assert_eq!(
tool_result_count(&agent, "call-1"),
1,
"the original real ToolResult must not be duplicated"
);
}
#[tokio::test]
async fn persist_cancelled_tool_results_only_tombstones_unresolved_ids() {
let mut agent = make_agent();
push_tool_result(&mut agent, "call-1", "real output", false);
agent
.persist_cancelled_tool_results(
&[tool_use_request("call-1"), tool_use_request("call-2")],
None,
)
.await;
assert_eq!(
tool_result_count(&agent, "call-1"),
1,
"already-resolved call-1 must not receive a second ToolResult"
);
assert_eq!(
tool_result_count(&agent, "call-2"),
1,
"unresolved call-2 must receive exactly one tombstone ToolResult"
);
let call2_is_tombstone = agent.msg.messages.iter().any(|m| {
m.parts.iter().any(|p| {
matches!(
p,
MessagePart::ToolResult { tool_use_id, content, is_error }
if tool_use_id == "call-2" && content == "[Cancelled]" && *is_error
)
})
});
assert!(
call2_is_tombstone,
"call-2's ToolResult must be the [Cancelled] tombstone"
);
}
#[tokio::test]
async fn persist_cancelled_tool_results_tombstones_all_ids_when_none_resolved() {
let mut agent = make_agent();
agent
.persist_cancelled_tool_results(
&[tool_use_request("call-1"), tool_use_request("call-2")],
None,
)
.await;
assert_eq!(tool_result_count(&agent, "call-1"), 1);
assert_eq!(tool_result_count(&agent, "call-2"), 1);
}
#[tokio::test]
async fn persist_cancelled_tool_results_writes_tombstone_for_id_reused_in_a_new_turn() {
let mut agent = make_agent();
push_tool_result(&mut agent, "call_0", "turn-1 output", false);
agent.msg.messages.push(Message {
role: Role::Assistant,
content: "[tool_use: bash(call_0)]".to_owned(),
parts: vec![MessagePart::ToolUse {
id: "call_0".to_owned(),
name: "bash".to_owned(),
input: serde_json::json!({}),
}],
metadata: MessageMetadata::default(),
});
agent
.persist_cancelled_tool_results(&[tool_use_request("call_0")], None)
.await;
assert_eq!(
tool_result_count(&agent, "call_0"),
2,
"turn 2's cancelled call_0 must still receive its own tombstone ToolResult, \
separate from turn 1's real result — the guard must not treat a new turn's \
id-reused call as already resolved"
);
}
#[tokio::test]
async fn persist_cancelled_tool_results_some_index_inserts_at_that_position() {
let mut agent = make_agent();
agent.msg.messages.push(Message {
role: Role::Assistant,
content: "[tool_use]".to_owned(),
parts: vec![MessagePart::ToolUse {
id: "call-1".to_owned(),
name: "bash".to_owned(),
input: serde_json::json!({}),
}],
metadata: MessageMetadata::default(),
});
let orphan_idx = agent.msg.messages.len() - 1;
agent.msg.messages.push(Message {
role: Role::User,
content: "later unrelated message".to_owned(),
parts: vec![MessagePart::Text {
text: "later unrelated message".to_owned(),
}],
metadata: MessageMetadata::default(),
});
let messages_before = agent.msg.messages.len();
agent
.persist_cancelled_tool_results(&[tool_use_request("call-1")], Some(orphan_idx + 1))
.await;
assert_eq!(
agent.msg.messages.len(),
messages_before + 1,
"exactly one tombstone message must be inserted"
);
assert!(
agent.msg.messages[orphan_idx + 1]
.parts
.iter()
.any(|p| matches!(
p,
MessagePart::ToolResult { tool_use_id, is_error, .. }
if tool_use_id == "call-1" && *is_error
)),
"the tombstone must be spliced in at insert_at, not appended at the true end"
);
assert_eq!(
agent.msg.messages[orphan_idx + 2].content,
"later unrelated message",
"the later message must be pushed one slot forward by the insertion, not displaced"
);
}