use super::*;
use crate::error::{ConfigError, Error};
use crate::llm::{ChatRequest, ChatResponse};
use crate::memory::Episode;
use crate::runtime::{run_steps, CaptureSink, RunOptions};
use crate::test_utils::{fake_context, FakeLlmClient, FakeLlmStep};
use std::sync::{Arc, Mutex};
fn padded(tag: &str) -> String {
format!("{tag}-{}", "x".repeat(76))
}
fn seed_messages(n: usize) -> Vec<Message> {
(0..n)
.map(|i| Message {
role: if i % 2 == 0 {
Role::User
} else {
Role::Assistant
},
content: padded(&format!("turn{i}")),
tool_calls: vec![],
tool_call_id: None,
})
.collect()
}
fn tiny_messages(n: usize) -> Vec<Message> {
(0..n)
.map(|i| Message {
role: if i % 2 == 0 {
Role::User
} else {
Role::Assistant
},
content: format!("m{i}"),
tool_calls: vec![],
tool_call_id: None,
})
.collect()
}
fn small_compaction_opts() -> SummarizeOptions {
SummarizeOptions {
trigger_token_budget: 50,
keep_recent_messages: 2,
..SummarizeOptions::default()
}
}
async fn seed(ctx: &crate::agent::AgentContext, thread: &ThreadId, messages: Vec<Message>) {
for msg in messages {
ctx.short_term.append(thread.clone(), msg).await.unwrap();
}
}
#[tokio::test]
async fn compacts_history_over_budget_keeping_last_n_verbatim() {
let mut ctx = fake_context("compaction-over-budget");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("summary of the earlier turns".into()),
FakeLlmStep::Text("final answer".into()),
]));
let thread = ThreadId::new("t-over-budget");
seed(&ctx, &thread, seed_messages(5)).await;
let opts = RunOptions::default().with_compaction(small_compaction_opts());
let out = run_steps(&ctx, "sys", thread.clone(), opts).await.unwrap();
assert_eq!(out, "final answer");
let remaining = ctx.short_term.load(thread.clone(), 100_000).await.unwrap();
assert_eq!(
remaining.len(),
4,
"short-term must shrink to [summary, turn3, turn4, reply]: {remaining:?}"
);
assert!(remaining[0]
.content
.contains("summary of the earlier turns"));
assert!(remaining[1].content.contains("turn3"));
assert!(remaining[2].content.contains("turn4"));
assert_eq!(remaining[3].content, "final answer");
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
let checkpoints = episodes
.iter()
.filter(|e| matches!(e, Episode::SummaryCheckpoint { .. }))
.count();
assert_eq!(checkpoints, 1, "exactly one summarization must have run");
}
#[tokio::test]
async fn stays_under_budget_never_summarizes() {
let mut ctx = fake_context("compaction-under-budget");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("final".into())]));
let thread = ThreadId::new("t-under-budget");
seed(&ctx, &thread, tiny_messages(4)).await;
let opts = RunOptions::default().with_compaction(small_compaction_opts());
let out = run_steps(&ctx, "sys", thread.clone(), opts).await.unwrap();
assert_eq!(out, "final");
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
assert!(
!episodes
.iter()
.any(|e| matches!(e, Episode::SummaryCheckpoint { .. })),
"a history under budget must never trigger summarization"
);
let remaining = ctx.short_term.load(thread.clone(), 100_000).await.unwrap();
assert_eq!(
remaining.len(),
5,
"no compaction: original 4 turns plus the new reply, untouched"
);
}
#[tokio::test]
async fn original_history_survives_in_provenance_after_short_term_compaction() {
#[derive(Default)]
struct RecordingSink {
requests: Mutex<Vec<ChatRequest>>,
}
impl CaptureSink for RecordingSink {
fn record_llm_call(&self, request: &ChatRequest, _response: &ChatResponse) {
self.requests.lock().unwrap().push(request.clone());
}
}
const SEED_TEXT: &str = "SEED: the original ask";
let mut ctx = fake_context("compaction-moat");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text(padded("reply1")),
FakeLlmStep::Text(padded("reply2")),
FakeLlmStep::Text("condensed summary".into()),
FakeLlmStep::Text(padded("reply3")),
]));
let thread = ThreadId::new("t-moat");
ctx.short_term
.append(
thread.clone(),
Message {
role: Role::User,
content: SEED_TEXT.into(),
tool_calls: vec![],
tool_call_id: None,
},
)
.await
.unwrap();
let sink = Arc::new(RecordingSink::default());
let opts = RunOptions::default()
.with_compaction(SummarizeOptions {
trigger_token_budget: 40,
keep_recent_messages: 2,
..SummarizeOptions::default()
})
.with_capture_sink(sink.clone());
for _ in 0..3 {
run_steps(&ctx, "sys", thread.clone(), opts.clone())
.await
.unwrap();
}
let remaining = ctx.short_term.load(thread.clone(), 100_000).await.unwrap();
assert!(
!remaining.iter().any(|m| m.content == SEED_TEXT),
"short-term compaction is expected to drop the seed turn: {remaining:?}"
);
let seed_recovered = sink
.requests
.lock()
.unwrap()
.iter()
.any(|req| req.messages.iter().any(|m| m.content == SEED_TEXT));
assert!(
seed_recovered,
"original seed content must still be recoverable from captured requests"
);
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
let llm_calls = episodes
.iter()
.filter(|e| matches!(e, Episode::LlmCall { .. }))
.count();
let checkpoints = episodes
.iter()
.filter(|e| matches!(e, Episode::SummaryCheckpoint { .. }))
.count();
assert_eq!(llm_calls, 3, "episodic ledger must retain all 3 real steps");
assert_eq!(
checkpoints, 1,
"exactly one compaction fired across the 3 calls"
);
}
#[tokio::test]
async fn summarizer_llm_call_is_recorded_to_the_episodic_trail() {
let mut ctx = fake_context("compaction-audited");
ctx.llm = Arc::new(FakeLlmClient::new("fake").with_steps(vec![
FakeLlmStep::Text("condensed summary".into()),
FakeLlmStep::Text("final".into()),
]));
let thread = ThreadId::new("t-audited");
seed(&ctx, &thread, seed_messages(5)).await;
let opts = RunOptions::default().with_compaction(small_compaction_opts());
run_steps(&ctx, "sys", thread.clone(), opts).await.unwrap();
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
let checkpoint = episodes.iter().find_map(|e| match e {
Episode::SummaryCheckpoint {
input_message_count,
summary_chars,
..
} => Some((*input_message_count, *summary_chars)),
_ => None,
});
let (input_message_count, summary_chars) =
checkpoint.expect("the summarizer LLM call must be recorded as Episode::SummaryCheckpoint");
assert_eq!(
input_message_count, 3,
"3 older messages (5 loaded - keep_recent 2) were folded into the summary call"
);
assert_eq!(summary_chars, "condensed summary".chars().count() as u32);
}
#[tokio::test]
async fn without_compaction_disables_auto_summarization_even_over_budget() {
let mut ctx = fake_context("compaction-disabled");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("final".into())]));
let thread = ThreadId::new("t-disabled");
seed(&ctx, &thread, seed_messages(5)).await;
let opts = RunOptions::default()
.with_compaction(small_compaction_opts())
.without_compaction();
let out = run_steps(&ctx, "sys", thread.clone(), opts).await.unwrap();
assert_eq!(out, "final");
let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
assert!(
!episodes
.iter()
.any(|e| matches!(e, Episode::SummaryCheckpoint { .. })),
"compaction must never fire once disabled, regardless of history size"
);
let remaining = ctx.short_term.load(thread.clone(), 100_000).await.unwrap();
assert_eq!(
remaining.len(),
6,
"full original 5 turns plus the new reply, untouched"
);
}
#[tokio::test]
async fn zero_trigger_budget_is_a_typed_config_error_not_a_panic() {
let ctx = fake_context("compaction-zero-budget");
let thread = ThreadId::new("t-zero-budget");
let bad = SummarizeOptions {
trigger_token_budget: 0,
..SummarizeOptions::default()
};
let opts = RunOptions::default().with_compaction(bad);
let err = run_steps(&ctx, "sys", thread, opts).await.unwrap_err();
match err {
Error::Config(ConfigError::InvalidValue { key, .. }) => {
assert_eq!(key.as_str(), "compaction.trigger_token_budget");
}
other => panic!("expected Error::Config(InvalidValue), got {other:?}"),
}
}