use meerkat_core::compact::{
COMPACTION_SUMMARY_PREFIX, CompactionConfig, CompactionContext, CompactionResult,
CompactionSummary, Compactor,
};
use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, ContentBlock, Message};
const COMPACTION_PROMPT: &str = "\
You are performing a CONTEXT COMPACTION. Your job is to create a handoff summary so work can continue seamlessly.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences discovered
- What remains to be done (clear next steps)
- Any critical data, file paths, examples, or references needed to continue
- Tool call patterns that worked or failed
Be concise and structured. Prioritize information the next context needs to act, not narrate.";
pub struct DefaultCompactor {
config: CompactionConfig,
}
impl DefaultCompactor {
pub fn new(config: CompactionConfig) -> Self {
Self { config }
}
}
fn project_media_for_summarization(blocks: &[ContentBlock]) -> Vec<ContentBlock> {
blocks
.iter()
.map(|block| match block {
ContentBlock::Image { media_type, .. } => ContentBlock::Text {
text: format!("[image: {media_type}]"),
},
ContentBlock::Video { media_type, .. } => ContentBlock::Text {
text: format!("[video: {media_type}]"),
},
other => other.clone(),
})
.collect()
}
fn project_assistant_blocks_for_summarization(blocks: &[AssistantBlock]) -> Vec<AssistantBlock> {
blocks
.iter()
.filter(|b| !matches!(b, AssistantBlock::Reasoning { .. }))
.cloned()
.collect()
}
fn project_messages_for_summarization(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.filter_map(|msg| match msg {
Message::User(user) => {
let content = project_media_for_summarization(&user.content);
let mut user = user.clone();
user.content = content;
Some(Message::User(user))
}
Message::BlockAssistant(assistant) => {
let blocks = project_assistant_blocks_for_summarization(&assistant.blocks);
if blocks.is_empty() {
None
} else {
Some(Message::BlockAssistant(BlockAssistantMessage {
blocks,
stop_reason: assistant.stop_reason,
identity: meerkat_core::types::TranscriptMessageIdentity::default(),
created_at: assistant.created_at,
}))
}
}
Message::ToolResults {
results,
created_at,
} => {
let results = results
.iter()
.map(|r| {
let content = project_media_for_summarization(&r.content);
meerkat_core::types::ToolResult::with_blocks(
r.tool_use_id.clone(),
content,
r.is_error,
)
})
.collect();
Some(Message::ToolResults {
results,
created_at: *created_at,
})
}
other => Some(other.clone()),
})
.collect()
}
impl Compactor for DefaultCompactor {
fn request_byte_cap(&self, pressure: meerkat_core::ProviderRequestPressure) -> Option<u64> {
pressure.effective_cap(self.config.max_request_bytes)
}
fn should_compact(&self, ctx: &CompactionContext) -> bool {
if ctx.session_boundary_index == 0 {
return false;
}
if let Some(last) = ctx.last_compaction_boundary_index
&& ctx.session_boundary_index.saturating_sub(last)
< u64::from(self.config.min_turns_between_compactions)
{
return false;
}
let input_trigger = ctx.last_input_tokens >= self.config.auto_compact_threshold;
let history_trigger = ctx.estimated_history_tokens >= self.config.auto_compact_threshold;
let (request_bytes, byte_threshold, request_measurement) =
match ctx.provider_request_pressure {
Some(pressure) => (
pressure.encoded_bytes,
pressure.trigger_threshold(self.config.max_request_bytes),
"provider_lowered_exact",
),
None => (
ctx.estimated_request_bytes,
self.config.request_byte_trigger_threshold(),
"transcript_estimate",
),
};
let byte_trigger = byte_threshold.is_some_and(|threshold| request_bytes >= threshold);
if input_trigger || history_trigger || byte_trigger {
tracing::trace!(
input_tokens = ctx.last_input_tokens,
estimated_history_tokens = ctx.estimated_history_tokens,
estimated_request_bytes = ctx.estimated_request_bytes,
provider_request_bytes = ctx
.provider_request_pressure
.map(|pressure| pressure.encoded_bytes),
threshold = self.config.auto_compact_threshold,
byte_threshold,
request_measurement,
branch = if input_trigger {
"last_input_tokens"
} else if history_trigger {
"estimated_history_tokens_fallback"
} else if ctx.provider_request_pressure.is_some() {
"provider_request_bytes"
} else {
"estimated_request_bytes"
},
"compaction trigger fired",
);
}
input_trigger || history_trigger || byte_trigger
}
fn prepare_for_summarization(&self, messages: &[Message]) -> Vec<Message> {
project_messages_for_summarization(messages)
}
fn compaction_prompt(&self) -> &str {
COMPACTION_PROMPT
}
fn max_summary_tokens(&self) -> u32 {
self.config.max_summary_tokens
}
fn rebuild_history(&self, messages: &[Message], summary: &str) -> CompactionResult {
let mut rebuilt = Vec::new();
let mut retained = Vec::new();
let mut discarded = Vec::new();
let summary_content = format!("{COMPACTION_SUMMARY_PREFIX}{summary}");
let summary_message = Message::User(meerkat_core::types::UserMessage::compaction_summary(
summary_content,
));
let mut turn_starts: Vec<usize> = Vec::new();
for (i, msg) in messages.iter().enumerate() {
if matches!(msg, Message::User(u) if u.transcript_role.is_conversational()) {
let mut start = i;
while start > 0
&& matches!(
&messages[start - 1],
Message::User(u) if u.transcript_role.is_injected_context()
)
{
start -= 1;
}
turn_starts.push(start);
}
}
let retain_turn_count = if self.config.recent_turn_budget == 0 {
0
} else {
self.config
.recent_turn_budget
.min(turn_starts.len().saturating_sub(1))
};
let retain_from = if retain_turn_count == 0 {
messages.len()
} else {
turn_starts[turn_starts.len() - retain_turn_count]
};
let first_discarded_source_offset = messages
.iter()
.enumerate()
.find_map(|(source_offset, message)| {
(!matches!(message, Message::System(_)) && source_offset < retain_from)
.then_some(source_offset)
})
.unwrap_or(messages.len());
let mut summary_mapping = None;
for (source_offset, message) in messages.iter().enumerate() {
if source_offset == first_discarded_source_offset {
summary_mapping = Some(CompactionSummary::new(
u64::try_from(rebuilt.len()).unwrap_or(u64::MAX),
summary_message.clone(),
));
rebuilt.push(summary_message.clone());
}
let retain = matches!(message, Message::System(_)) || source_offset >= retain_from;
if retain {
retained.push(meerkat_core::compact::CompactionRetained::new(
u64::try_from(source_offset).unwrap_or(u64::MAX),
u64::try_from(rebuilt.len()).unwrap_or(u64::MAX),
message.clone(),
));
rebuilt.push(message.clone());
continue;
}
discarded.push(meerkat_core::compact::CompactionDiscard::new(
u64::try_from(source_offset).unwrap_or(u64::MAX),
message.clone(),
));
}
let summary_mapping = summary_mapping.unwrap_or_else(|| {
let mapping = CompactionSummary::new(
u64::try_from(rebuilt.len()).unwrap_or(u64::MAX),
summary_message.clone(),
);
rebuilt.push(summary_message);
mapping
});
CompactionResult {
messages: rebuilt,
summary: summary_mapping,
retained,
discarded,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use meerkat_core::BlobId;
use meerkat_core::types::{ImageData, SystemMessage, UserMessage, VideoData};
fn make_config() -> CompactionConfig {
CompactionConfig {
auto_compact_threshold: 100_000,
max_request_bytes: None,
recent_turn_budget: 2,
max_summary_tokens: 4096,
min_turns_between_compactions: 3,
}
}
fn inline_image_block(media_type: &str, data: &str) -> ContentBlock {
ContentBlock::Image {
media_type: media_type.to_string(),
data: ImageData::Inline {
data: data.to_string(),
},
}
}
fn blob_image_block(media_type: &str, blob_id: &str) -> ContentBlock {
ContentBlock::Image {
media_type: media_type.to_string(),
data: ImageData::Blob {
blob_id: BlobId::new(blob_id),
},
}
}
fn inline_video_block(media_type: &str, duration_ms: u64, data: &str) -> ContentBlock {
ContentBlock::Video {
media_type: media_type.to_string(),
duration_ms,
data: VideoData::Inline {
data: data.to_string(),
},
}
}
fn assert_blob_image(block: &ContentBlock, expected_media_type: &str, expected_blob_id: &str) {
match block {
ContentBlock::Image {
media_type,
data: ImageData::Blob { blob_id },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(blob_id.as_str(), expected_blob_id);
}
other => panic!("expected blob image block, got {other:?}"),
}
}
fn assert_inline_image(block: &ContentBlock, expected_media_type: &str, expected_data: &str) {
match block {
ContentBlock::Image {
media_type,
data: ImageData::Inline { data },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(data, expected_data);
}
other => panic!("expected inline image block, got {other:?}"),
}
}
fn assert_inline_video(
block: &ContentBlock,
expected_media_type: &str,
expected_duration_ms: u64,
expected_data: &str,
) {
match block {
ContentBlock::Video {
media_type,
duration_ms,
data: VideoData::Inline { data },
} => {
assert_eq!(media_type, expected_media_type);
assert_eq!(*duration_ms, expected_duration_ms);
assert_eq!(data, expected_data);
}
other => panic!("expected inline video block, got {other:?}"),
}
}
#[test]
fn test_should_compact_first_turn_never() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 0,
};
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_should_compact_loop_guard() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: Some(5),
session_boundary_index: 7, };
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_should_compact_follow_up_run_boundary_zero_no_longer_special() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 200_000,
message_count: 100,
estimated_history_tokens: 200_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 1,
};
assert!(c.should_compact(&ctx));
}
#[test]
fn test_should_compact_dual_threshold() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 100_000,
message_count: 50,
estimated_history_tokens: 50_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(c.should_compact(&ctx));
let ctx2 = CompactionContext {
last_input_tokens: 50_000,
message_count: 50,
estimated_history_tokens: 100_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(c.should_compact(&ctx2));
}
#[test]
fn test_voice_only_session_compacts_via_estimated_history_fallback() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 0,
message_count: 200,
estimated_history_tokens: 150_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 42,
};
assert!(
c.should_compact(&ctx),
"voice-only session must compact via estimated_history_tokens \
when last_input_tokens is zero",
);
}
#[test]
fn test_should_not_compact_when_neither_threshold_met() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 50_000,
message_count: 20,
estimated_history_tokens: 50_000,
estimated_request_bytes: 0,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(!c.should_compact(&ctx));
}
#[test]
fn test_byte_trigger_fires_below_token_threshold_on_byte_heavy_transcript() {
let c = DefaultCompactor::new(CompactionConfig {
max_request_bytes: Some(9_000_000),
..make_config()
});
let ctx = CompactionContext {
last_input_tokens: 10_000,
message_count: 40,
estimated_history_tokens: 12_000,
estimated_request_bytes: 7_200_000, provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(
c.should_compact(&ctx),
"byte trigger must fire on a byte-heavy transcript before any token threshold"
);
let below = CompactionContext {
estimated_request_bytes: 7_199_999,
..ctx
};
assert!(!c.should_compact(&below));
}
#[test]
fn test_token_trigger_unchanged_when_byte_trigger_unset() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 50_000,
message_count: 50,
estimated_history_tokens: 50_000,
estimated_request_bytes: u64::MAX,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(
!c.should_compact(&ctx),
"an unset byte cap must leave the trigger decision to the token thresholds"
);
let token_crossing = CompactionContext {
last_input_tokens: 100_000,
..ctx
};
assert!(c.should_compact(&token_crossing));
}
#[test]
fn exact_provider_witness_supplies_the_dynamic_active_cap() {
let c = DefaultCompactor::new(make_config());
let ctx = CompactionContext {
last_input_tokens: 1,
message_count: 2,
estimated_history_tokens: 1,
estimated_request_bytes: 128,
provider_request_pressure: Some(meerkat_core::ProviderRequestPressure::new(
7_200_000,
Some(9_000_000),
)),
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(
c.should_compact(&ctx),
"the active provider's exact lowered body and cap must override the blind transcript estimate"
);
}
#[test]
fn test_both_triggers_set_first_crossing_wins() {
let c = DefaultCompactor::new(CompactionConfig {
max_request_bytes: Some(9_000_000),
..make_config()
});
let neither = CompactionContext {
last_input_tokens: 50_000,
message_count: 50,
estimated_history_tokens: 50_000,
estimated_request_bytes: 1_000_000,
provider_request_pressure: None,
last_compaction_boundary_index: None,
session_boundary_index: 5,
};
assert!(!c.should_compact(&neither));
let bytes_first = CompactionContext {
estimated_request_bytes: 8_000_000,
..neither
};
assert!(
c.should_compact(&bytes_first),
"byte crossing alone must fire when both triggers are armed"
);
let tokens_first = CompactionContext {
last_input_tokens: 100_000,
..neither
};
assert!(
c.should_compact(&tokens_first),
"token crossing alone must fire when both triggers are armed"
);
}
#[test]
fn rebuild_preserves_ordered_system_message() {
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::System(SystemMessage::new("system")),
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
Message::User(UserMessage::text("turn3")),
];
let result = c.rebuild_history(&messages, "summary text");
assert!(matches!(&result.messages[0], Message::System(s) if s.content == "system"));
assert_eq!(result.summary.rebuilt_offset, 1);
assert_eq!(result.messages[1], result.summary.message);
assert_eq!(result.discarded.len(), 1);
assert_eq!(result.discarded[0].source_offset, 1);
assert!(matches!(
&result.discarded[0].message,
Message::User(u) if u.text_content() == "turn1"
));
assert_eq!(result.retained[0].source_offset, 0);
assert_eq!(result.retained[0].rebuilt_offset, 0);
}
#[test]
fn rebuild_preserves_mid_thread_system_in_order_across_compaction() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage::new("initial prompt")),
Message::User(UserMessage::text("turn1")),
Message::System(SystemMessage::new("mid-thread instruction")),
Message::User(UserMessage::text("turn2")),
Message::User(UserMessage::text("turn3")),
];
let result = c.rebuild_history(&messages, "summary");
assert!(matches!(
&result.messages[0],
Message::System(system) if system.content == "initial prompt"
));
assert_eq!(result.messages[1], result.summary.message);
assert!(matches!(
&result.messages[2],
Message::System(system) if system.content == "mid-thread instruction"
));
assert!(
result
.retained
.iter()
.any(|retention| retention.source_offset == 2 && retention.rebuilt_offset == 2),
"the mid-thread System message must remain an exact retained row"
);
assert!(
result
.discarded
.iter()
.all(|discard| !matches!(discard.message, Message::System(_))),
"compaction may never discard an ordered System message"
);
}
#[test]
fn rebuild_places_summary_after_complete_retained_instruction_prefix() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage::new("system A")),
Message::System(SystemMessage::new("system B")),
Message::User(UserMessage::text("old turn")),
Message::User(UserMessage::text("recent turn")),
];
let result = c.rebuild_history(&messages, "summary");
assert!(matches!(
&result.messages[0],
Message::System(system) if system.content == "system A"
));
assert!(matches!(
&result.messages[1],
Message::System(system) if system.content == "system B"
));
assert_eq!(result.summary.rebuilt_offset, 2);
assert_eq!(result.messages[2], result.summary.message);
assert_eq!(
result
.retained
.iter()
.map(|retention| (retention.source_offset, retention.rebuilt_offset))
.collect::<Vec<_>>(),
vec![(0, 0), (1, 1), (3, 3)]
);
assert_eq!(result.discarded.len(), 1);
assert_eq!(result.discarded[0].source_offset, 2);
}
#[test]
fn test_rebuild_keeps_recent_turns_not_just_user() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
Message::User(UserMessage::text("turn3")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2); assert_eq!(result.discarded.len(), 2); }
#[test]
fn test_rebuild_below_turn_budget_still_discards_oldest_turn() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 4,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), messages.len());
assert_eq!(result.discarded.len(), 1);
assert_eq!(result.discarded[0].source_offset, 0);
assert!(matches!(
&result.messages[0],
Message::User(user) if user.transcript_role.is_compaction_summary()
));
assert!(matches!(
&result.messages[1],
Message::User(user) if user.text_content() == "turn2"
));
}
#[test]
fn test_rebuild_below_turn_budget_discards_prior_summary_and_oldest_live_turn() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 4,
..make_config()
});
let messages = vec![
Message::User(UserMessage::compaction_summary("old summary")),
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2);
assert_eq!(result.discarded.len(), 2);
assert!(matches!(
&result.discarded[0].message,
Message::User(user) if user.transcript_role.is_compaction_summary()
));
assert!(matches!(
&result.discarded[1].message,
Message::User(user) if user.text_content() == "turn1"
));
assert!(matches!(
&result.messages[1],
Message::User(user) if user.text_content() == "turn2"
));
assert!(result.retained.iter().all(|retention| !matches!(
&retention.message,
Message::User(user) if user.transcript_role.is_compaction_summary()
)));
}
#[test]
fn test_rebuild_injected_context_does_not_start_or_dilute_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::injected_context("ambient a")),
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::injected_context("ambient b1")),
Message::User(UserMessage::injected_context("ambient b2")),
Message::User(UserMessage::text("turn2")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 4);
assert!(matches!(
&result.messages[1],
Message::User(u) if u.transcript_role.is_injected_context()
&& u.text_content() == "ambient b1"
));
assert!(matches!(
&result.messages[3],
Message::User(u) if u.text_content() == "turn2"
));
assert_eq!(result.discarded.len(), 2);
}
#[test]
fn test_rebuild_prior_summary_does_not_count_as_turn() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::compaction_summary("[Context compacted] old")),
Message::User(UserMessage::text("turn1")),
Message::User(UserMessage::text("turn2")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2, "new summary + retained turn2");
assert!(matches!(
&result.messages[1],
Message::User(u) if u.text_content() == "turn2"
));
assert_eq!(result.discarded.len(), 2, "prior summary + turn1 discarded");
assert!(matches!(
&result.discarded[0].message,
Message::User(u) if u.transcript_role.is_compaction_summary()
));
}
#[test]
fn test_rebuild_respects_turn_budget() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 2,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("t1")),
Message::User(UserMessage::text("t2")),
Message::User(UserMessage::text("t3")),
Message::User(UserMessage::text("t4")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 3);
assert_eq!(result.discarded.len(), 2); }
#[test]
fn test_rebuild_budget_larger_than_history_still_replaces_oldest_turn() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 10,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("t1")),
Message::User(UserMessage::text("t2")),
Message::User(UserMessage::text("t3")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 3);
assert_eq!(result.discarded.len(), 1);
assert!(matches!(
&result.discarded[0].message,
Message::User(user) if user.text_content() == "t1"
));
}
#[test]
fn test_rebuild_discarded_messages_in_order() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("a")),
Message::User(UserMessage::text("b")),
Message::User(UserMessage::text("c")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.discarded.len(), 2);
if let Message::User(u) = &result.discarded[0].message {
assert_eq!(u.text_content(), "a");
}
if let Message::User(u) = &result.discarded[1].message {
assert_eq!(u.text_content(), "b");
}
}
#[test]
fn test_rebuild_zero_budget_discards_all() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 0,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("a")),
Message::User(UserMessage::text("b")),
Message::User(UserMessage::text("c")),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 1);
assert_eq!(result.discarded.len(), 3);
}
#[test]
fn test_rebuild_with_block_assistant_and_tool_results() {
use meerkat_core::types::{AssistantBlock, BlockAssistantMessage, StopReason, ToolResult};
use serde_json::value::RawValue;
let args_raw = RawValue::from_string(r#"{"city":"Tokyo"}"#.to_string()).unwrap();
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage::new("You are helpful.")),
Message::User(UserMessage::text("What is the weather?")),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::ToolUse {
id: "tc_1".to_string(),
name: "get_weather".to_string(),
args: args_raw,
meta: None,
}],
StopReason::ToolUse,
)),
Message::tool_results(vec![ToolResult::new(
"tc_1".to_string(),
"Sunny, 25C".to_string(),
false,
)]),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::Text {
text: "It's sunny in Tokyo!".to_string(),
meta: None,
}],
StopReason::EndTurn,
)),
Message::User(UserMessage::text("Thanks!")),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::Text {
text: "You're welcome!".to_string(),
meta: None,
}],
StopReason::EndTurn,
)),
];
let result = c.rebuild_history(&messages, "Summary of weather conversation");
assert_eq!(result.messages.len(), 4); assert!(matches!(&result.messages[0], Message::System(_)));
assert_eq!(result.discarded.len(), 4);
}
#[test]
fn summary_projection_replaces_media_preserves_text() {
let blocks = vec![
ContentBlock::Text {
text: "hello".to_string(),
},
inline_image_block("image/png", "base64data"),
inline_video_block("video/mp4", 5_000, "videodata"),
ContentBlock::Text {
text: "world".to_string(),
},
];
let result = project_media_for_summarization(&blocks);
assert_eq!(result.len(), 4);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "hello"));
assert!(matches!(&result[1], ContentBlock::Text { text } if text == "[image: image/png]"));
assert!(matches!(&result[2], ContentBlock::Text { text } if text == "[video: video/mp4]"));
assert!(matches!(&result[3], ContentBlock::Text { text } if text == "world"));
}
#[test]
fn compaction_image_placeholder_excludes_source_path() {
let blocks = vec![inline_image_block("image/png", "base64data")];
let result = project_media_for_summarization(&blocks);
assert_eq!(result.len(), 1);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "[image: image/png]"));
if let ContentBlock::Text { text } = &result[0] {
assert!(
!text.contains("/tmp/x.png"),
"source_path must not leak into placeholder"
);
}
}
#[test]
fn compaction_text_only_unchanged() {
let blocks = vec![
ContentBlock::Text {
text: "one".to_string(),
},
ContentBlock::Text {
text: "two".to_string(),
},
];
let result = project_media_for_summarization(&blocks);
assert_eq!(result.len(), 2);
assert!(matches!(&result[0], ContentBlock::Text { text } if text == "one"));
assert!(matches!(&result[1], ContentBlock::Text { text } if text == "two"));
}
#[test]
fn prepare_for_summarization_projects_user_and_tool_media() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "Look at this".to_string(),
},
inline_image_block("image/jpeg", "bigdata"),
inline_video_block("video/mp4", 5_000, "video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tc_1".to_string(),
vec![
ContentBlock::Text {
text: "screenshot captured".to_string(),
},
inline_image_block("image/png", "screenshotdata"),
inline_video_block("video/webm", 7_000, "toolvideo"),
],
false,
)]),
];
let prepared = c.prepare_for_summarization(&messages);
assert_eq!(prepared.len(), 2);
if let Message::User(u) = &prepared[0] {
assert_eq!(u.content.len(), 3);
assert!(matches!(&u.content[0], ContentBlock::Text { text } if text == "Look at this"));
assert!(
matches!(&u.content[1], ContentBlock::Text { text } if text == "[image: image/jpeg]")
);
assert!(
matches!(&u.content[2], ContentBlock::Text { text } if text == "[video: video/mp4]")
);
} else {
panic!("expected User message");
}
if let Message::ToolResults { results, .. } = &prepared[1] {
assert_eq!(results.len(), 1);
assert_eq!(results[0].content.len(), 3);
assert!(
matches!(&results[0].content[0], ContentBlock::Text { text } if text == "screenshot captured")
);
assert!(
matches!(&results[0].content[1], ContentBlock::Text { text } if text == "[image: image/png]")
);
assert!(
matches!(&results[0].content[2], ContentBlock::Text { text } if text == "[video: video/webm]")
);
} else {
panic!("expected ToolResults message");
}
}
#[test]
fn prepare_for_summarization_projects_media_without_mutating_source_history() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "keep source typed".to_string(),
},
blob_image_block("image/png", "sha256:source-image"),
inline_video_block("video/webm", 3_000, "source-video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tool_1".to_string(),
vec![
ContentBlock::Text {
text: "tool media".to_string(),
},
inline_image_block("image/jpeg", "tool-image"),
],
true,
)]),
];
let prepared = c.prepare_for_summarization(&messages);
match &prepared[0] {
Message::User(user) => {
assert_eq!(user.content.len(), 3);
assert!(
matches!(&user.content[1], ContentBlock::Text { text } if text == "[image: image/png]")
);
assert!(
matches!(&user.content[2], ContentBlock::Text { text } if text == "[video: video/webm]")
);
}
other => panic!("expected projected user message, got {other:?}"),
}
match &prepared[1] {
Message::ToolResults { results, .. } => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_use_id, "tool_1");
assert!(results[0].is_error);
assert!(
matches!(&results[0].content[1], ContentBlock::Text { text } if text == "[image: image/jpeg]")
);
}
other => panic!("expected projected tool results, got {other:?}"),
}
match &messages[0] {
Message::User(user) => {
assert_blob_image(&user.content[1], "image/png", "sha256:source-image");
assert_inline_video(&user.content[2], "video/webm", 3_000, "source-video");
}
other => panic!("expected original user message, got {other:?}"),
}
match &messages[1] {
Message::ToolResults { results, .. } => {
assert_inline_image(&results[0].content[1], "image/jpeg", "tool-image");
}
other => panic!("expected original tool results, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_videos_from_retained_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old text turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest with video".to_string(),
},
inline_video_block("video/mp4", 5_000, "video-data"),
])),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2, "summary + retained turn");
let retained = result.messages.last().expect("retained turn");
match retained {
Message::User(user) => {
assert_eq!(user.content.len(), 2);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest with video"
));
assert_inline_video(&user.content[1], "video/mp4", 5_000, "video-data");
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_blob_images_from_retained_turns() {
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old text turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest with image".to_string(),
},
blob_image_block("image/png", "sha256:test"),
])),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(result.messages.len(), 2, "summary + retained turn");
let retained = result.messages.last().expect("retained turn");
match retained {
Message::User(user) => {
assert_eq!(user.content.len(), 2);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest with image"
));
assert_blob_image(&user.content[1], "image/png", "sha256:test");
}
other => panic!("expected retained user turn, got {other:?}"),
}
}
#[test]
fn rebuild_history_preserves_tool_result_images_from_retained_turns() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::User(UserMessage::text("old turn")),
Message::User(UserMessage::text("latest turn")),
Message::tool_results(vec![ToolResult::with_blocks(
"tool_1".to_string(),
vec![
ContentBlock::Text {
text: "saw this".to_string(),
},
inline_image_block("image/jpeg", "abc"),
],
false,
)]),
];
let result = c.rebuild_history(&messages, "summary");
assert_eq!(
result.messages.len(),
3,
"summary + retained user + tool results"
);
match &result.messages[2] {
Message::ToolResults { results, .. } => {
assert_eq!(results.len(), 1);
assert_inline_image(&results[0].content[1], "image/jpeg", "abc");
}
other => panic!("expected retained tool results, got {other:?}"),
}
}
#[test]
fn rebuild_history_retained_multimodal_shape_survives_json_roundtrip() {
use meerkat_core::types::ToolResult;
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let messages = vec![
Message::System(SystemMessage::new("system")),
Message::User(UserMessage::text("discarded turn")),
Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "latest media".to_string(),
},
blob_image_block("image/png", "sha256:latest-image"),
inline_video_block("video/mp4", 8_000, "latest-video"),
])),
Message::tool_results(vec![ToolResult::with_blocks(
"tool_2".to_string(),
vec![
ContentBlock::Text {
text: "tool image".to_string(),
},
inline_image_block("image/jpeg", "tool-image"),
],
false,
)]),
];
let result = c.rebuild_history(&messages, "summary");
let json = serde_json::to_string(&result.messages).expect("serialize rebuilt transcript");
let round_tripped: Vec<Message> =
serde_json::from_str(&json).expect("deserialize rebuilt transcript");
assert_eq!(
round_tripped.len(),
4,
"system + summary + retained user + retained tool results"
);
match &round_tripped[2] {
Message::User(user) => {
assert_eq!(user.content.len(), 3);
assert!(matches!(
&user.content[0],
ContentBlock::Text { text } if text == "latest media"
));
assert_blob_image(&user.content[1], "image/png", "sha256:latest-image");
assert_inline_video(&user.content[2], "video/mp4", 8_000, "latest-video");
}
other => panic!("expected retained user message after roundtrip, got {other:?}"),
}
match &round_tripped[3] {
Message::ToolResults { results, .. } => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_use_id, "tool_2");
assert_inline_image(&results[0].content[1], "image/jpeg", "tool-image");
}
other => panic!("expected retained tool results after roundtrip, got {other:?}"),
}
assert!(
!json.contains("[image:") && !json.contains("[video:"),
"retained transcript JSON must keep typed media blocks, not summary placeholders: {json}"
);
}
#[test]
fn discarded_prior_compaction_summary_is_not_reindexed() {
use meerkat_core::types::{MemoryIndexExclusion, MemoryIndexableContent};
let c = DefaultCompactor::new(CompactionConfig {
recent_turn_budget: 1,
..make_config()
});
let first_pass = c.rebuild_history(
&[
Message::User(UserMessage::text("original turn")),
Message::User(UserMessage::text("second turn")),
],
"summary of original work",
);
let mut messages = first_pass.messages;
messages.push(Message::User(UserMessage::text("post-compaction turn 1")));
messages.push(Message::User(UserMessage::text("post-compaction turn 2")));
let result = c.rebuild_history(&messages, "summary of everything");
let discarded_summary = result
.discarded
.iter()
.find(|discard| {
matches!(
&discard.message,
Message::User(user) if user.transcript_role.is_compaction_summary()
)
})
.expect("prior compaction summary must be in the discard set");
assert_eq!(
discarded_summary.message.indexable_content(),
MemoryIndexableContent::Excluded(MemoryIndexExclusion::CompactionSummary),
"discarded prior summary must carry the typed exclusion, not re-index"
);
let discarded_turn = result
.discarded
.iter()
.find(|discard| {
matches!(
&discard.message,
Message::User(user) if user.transcript_role.is_conversational()
)
})
.expect("a conversational turn is also discarded");
assert!(
discarded_turn.message.indexable_content().is_indexable(),
"conversational discards remain indexable"
);
}
#[test]
fn prepare_for_summarization_strips_reasoning_blocks() {
use meerkat_core::types::{ProviderMeta, StopReason};
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::text("Hello".to_string())),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![
AssistantBlock::Reasoning {
text: "Let me think".to_string(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_1".to_string(),
encrypted_content: Some("enc_data".to_string()),
phase: None,
response_id: None,
})),
},
AssistantBlock::Text {
text: "Here is my answer".to_string(),
meta: None,
},
],
StopReason::EndTurn,
)),
];
let prepared = c.prepare_for_summarization(&messages);
assert_eq!(prepared.len(), 2);
if let Message::BlockAssistant(a) = &prepared[1] {
assert_eq!(a.blocks.len(), 1);
assert!(
matches!(&a.blocks[0], AssistantBlock::Text { text, .. } if text == "Here is my answer")
);
} else {
panic!("expected BlockAssistant message");
}
}
#[test]
fn prepare_for_summarization_drops_reasoning_only_assistant() {
use meerkat_core::types::{ProviderMeta, StopReason};
let c = DefaultCompactor::new(make_config());
let messages = vec![
Message::User(UserMessage::text("First".to_string())),
Message::BlockAssistant(BlockAssistantMessage::new(
vec![AssistantBlock::Reasoning {
text: String::new(),
meta: Some(Box::new(ProviderMeta::OpenAi {
id: "rs_orphan".to_string(),
encrypted_content: Some("enc".to_string()),
phase: None,
response_id: None,
})),
}],
StopReason::EndTurn,
)),
Message::User(UserMessage::text("Second".to_string())),
];
let prepared = c.prepare_for_summarization(&messages);
assert_eq!(
prepared.len(),
2,
"reasoning-only assistant should be dropped"
);
assert!(matches!(&prepared[0], Message::User(u) if u.text_content() == "First"));
assert!(matches!(&prepared[1], Message::User(u) if u.text_content() == "Second"));
}
}