use super::*;
use crate::llm::{Message, MessageContent, Role, ToolResultBody};
fn user(text: &str) -> Message {
Message {
role: Role::User,
content: vec![MessageContent::Text {
text: text.to_string(),
}]
.into(),
}
}
fn assistant(text: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![MessageContent::Text {
text: text.to_string(),
}]
.into(),
}
}
fn assistant_tool_use(id: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![MessageContent::ToolUse {
id: id.to_string(),
name: "read".to_string(),
args: serde_json::json!({}),
}]
.into(),
}
}
fn tool_result(id: &str, text: &str) -> Message {
Message {
role: Role::User,
content: vec![MessageContent::ToolResult {
tool_use_id: id.to_string(),
output: ToolResultBody::Text {
text: text.to_string(),
},
is_error: false,
}]
.into(),
}
}
#[test]
fn turn_start_requires_non_tool_result_user_content() {
assert!(is_turn_start(&user("hi")));
assert!(!is_turn_start(&assistant("hello")));
assert!(!is_turn_start(&tool_result("t1", "out")));
}
#[test]
fn single_turn_falls_back_to_assistant_boundary() {
let messages = vec![user("only"), assistant("reply")];
assert_eq!(select_boundary(&messages, 8_000), Some(1));
}
#[test]
fn single_turn_long_autonomous_loop_compacts_via_assistant_boundary() {
let mut messages = vec![user("do the whole task")]; for i in 0..20 {
messages.push(assistant_tool_use(&format!("call_{i}")));
messages.push(tool_result(&format!("call_{i}"), "output"));
}
let boundary = select_boundary(&messages, 8_000).expect("must compact, not skip");
assert!(boundary > 0, "head must be non-empty");
assert_eq!(messages[boundary].role, Role::Assistant);
let (_head, tail) = messages.split_at(boundary);
assert_eq!(tail.first().expect("tail non-empty").role, Role::Assistant);
}
#[test]
fn empty_history_returns_none() {
assert_eq!(select_boundary(&[], 8_000), None);
}
#[test]
fn boundary_keeps_recent_turns_within_budget() {
let messages = vec![
user("turn1 user"), assistant("turn1 reply"), user("turn2 user"), assistant("turn2 reply"), user("turn3 user"), assistant("turn3 reply"), ];
let boundary = select_boundary(&messages, 1_000_000).expect("boundary");
assert_eq!(boundary, 2);
let (head, tail) = messages.split_at(boundary);
assert_eq!(head.len(), 2);
assert!(is_turn_start(tail.first().expect("tail non-empty")));
}
#[test]
fn tiny_budget_keeps_only_last_turn() {
let messages = vec![
user("turn1"),
assistant("r1"),
user("turn2"),
assistant("r2"),
user("turn3"),
assistant("r3"),
];
let boundary = select_boundary(&messages, 1).expect("boundary");
assert_eq!(boundary, 4);
}
#[test]
fn boundary_never_splits_tool_use_result_pair() {
let messages = vec![
user("turn1"), assistant("r1"), user("turn2"), assistant_tool_use("call_a"), tool_result("call_a", "out"), assistant("r2"), ];
let boundary = select_boundary(&messages, 1_000_000).expect("boundary");
assert_eq!(boundary, 2);
let (_head, tail) = messages.split_at(boundary);
assert!(is_turn_start(tail.first().expect("tail non-empty")));
}
#[test]
fn extract_previous_summary_strips_prefix() {
let summary_body = "## Goal\nbuild a thing";
let head = vec![
user("earlier"),
Message {
role: Role::Assistant,
content: vec![MessageContent::Text {
text: format!("{SUMMARY_PREFIX}\n{summary_body}"),
}]
.into(),
},
];
assert_eq!(
extract_previous_summary(&head).as_deref(),
Some(summary_body)
);
}
#[test]
fn extract_previous_summary_none_when_absent() {
let head = vec![user("a"), assistant("regular reply")];
assert_eq!(extract_previous_summary(&head), None);
}
#[test]
fn truncate_chars_respects_multibyte_boundary() {
let s = "héllo wörld"; let out = truncate_chars(s, 5);
assert!(out.starts_with("héllo"));
assert!(out.contains("truncated"));
assert_eq!(truncate_chars("short", 100), "short");
}
#[test]
fn prepare_head_message_truncates_tool_output_and_strips_image() {
let long = "x".repeat(TOOL_RESULT_MAX_CHARS + 500);
let msg = Message {
role: Role::User,
content: vec![
MessageContent::ToolResult {
tool_use_id: "t1".to_string(),
output: ToolResultBody::Text { text: long },
is_error: false,
},
MessageContent::Image {
mime: "image/png".to_string(),
data: crate::llm::ImageData::Base64 {
encoded: "AAAA".to_string(),
},
},
]
.into(),
};
let prepared = prepare_head_message(&msg);
match prepared.content.first().expect("tool result content") {
MessageContent::ToolResult { output, .. } => match output {
ToolResultBody::Text { text } => {
assert!(text.chars().count() <= TOOL_RESULT_MAX_CHARS + 40);
assert!(text.contains("truncated"));
}
_ => panic!("expected text output"),
},
_ => panic!("expected tool result"),
}
assert!(matches!(
prepared.content.get(1).expect("image placeholder"),
MessageContent::Text { text } if text.contains("image omitted")
));
}
#[test]
fn build_prompt_wraps_previous_summary() {
let with_prev = build_prompt(Some("old summary"));
assert!(with_prev.contains("<previous-summary>"));
assert!(with_prev.contains("old summary"));
let without = build_prompt(None);
assert!(!without.contains("<previous-summary>"));
}