Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub(super) fn project_compaction_rehydrate(r: &CompactionRehydrate) -> Vec<Messa
input: serde_json::json!({ "file_path": f.path }),
})
.collect();
let user_blocks: Vec<ContentBlock> = r
let mut user_blocks: Vec<ContentBlock> = r
.files
.iter()
.map(|f| ContentBlock::ToolResult {
Expand All @@ -50,6 +50,9 @@ pub(super) fn project_compaction_rehydrate(r: &CompactionRehydrate) -> Vec<Messa
metadata: None,
})
.collect();
if let Some(note) = r.partial_note.as_ref() {
user_blocks.push(ContentBlock::Text { text: note.clone() });
}
vec![
Message {
id: None,
Expand Down
6 changes: 6 additions & 0 deletions crates/loopal-provider/src/anthropic/request_turns/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ fn push_compaction_rehydrate(out: &mut Vec<Value>, r: &CompactionRehydrate) {
}]
}));
}
if let Some(note) = r.partial_note.as_ref() {
out.push(json!({
"role": "user",
"content": [{"type": "text", "text": note}],
}));
}
}

#[cfg(test)]
Expand Down
5 changes: 5 additions & 0 deletions crates/loopal-runtime/src/agent_loop/compact_rehydrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,15 @@ impl AgentLoopRunner {
};

let rehydrated_files = collect_rehydrated_files(&assistant.content, &user.content);
let partial_note = user.content.iter().find_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
_ => None,
});
if !rehydrated_files.is_empty()
&& let Err(e) =
self.append_step_record(TurnStep::CompactionRehydrate(CompactionRehydrate {
files: rehydrated_files,
partial_note,
}))
{
warn!(error = %e, "append_step(CompactionRehydrate) failed");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
use loopal_context::ContextBudget;
use loopal_protocol::AgentEventPayload;
use loopal_provider_api::{ContentBlock, Message};
use loopal_test_support::{HarnessBuilder, chunks};

fn tiny_budget() -> ContextBudget {
ContextBudget {
context_window: 500,
system_tokens: 0,
tool_tokens: 0,
output_reserve: 50,
safety_margin: 25,
message_budget: 425,
max_output_tokens: 50,
}
}

fn padded_seed(n: usize) -> Vec<Message> {
(0..n)
.map(|i| Message::user(&format!("msg-{i}: {}", "x".repeat(100))))
.collect()
}

fn first_text(h: &loopal_test_support::IntegrationHarness) -> String {
h.runner
.turns
.view()
.messages()
.first()
.and_then(|m| {
m.content.iter().find_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
})
.unwrap_or_default()
}

#[tokio::test]
async fn compact_falls_back_to_bare_summary_on_llm_failure() {
let mut h = HarnessBuilder::new()
.calls(vec![vec![chunks::non_retryable_error("simulated 400")]])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

let before = h.runner.turns.view().len();
h.runner.force_compact(None).await.unwrap();

assert!(
h.runner.turns.view().len() < before,
"bare_summary fallback must still reduce wire size: before={before}, after={}",
h.runner.turns.view().len()
);

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
let summary = evts
.iter()
.find_map(|e| match e {
AgentEventPayload::Compacted(s) => Some(s),
_ => None,
})
.expect("Compacted event must fire even on LLM failure");
assert!(summary.removed > 0);
assert!(summary.kept > 0);
}

#[tokio::test]
async fn compact_bare_summary_persists_deterministic_outline() {
let mut h = HarnessBuilder::new()
.calls(vec![vec![chunks::non_retryable_error(
"simulated 500-class",
)]])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

h.runner.force_compact(None).await.unwrap();

let text = first_text(&h);
assert!(
text.contains("Bare Summary") && text.contains("User turns:"),
"expected deterministic bare_summary outline, got: {text:?}"
);
}

#[tokio::test]
async fn compact_falls_back_to_bare_summary_on_empty_llm_response() {
// LLM returns Ok(Text{""}) — distinct from the Err path. extract_summary
// strips to "" and smart_compact MUST fall back to bare_summary rather
// than persisting an empty summary message.
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("")])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

let before = h.runner.turns.view().len();
h.runner.force_compact(None).await.unwrap();

assert!(
h.runner.turns.view().len() < before,
"empty-response fallback must still reduce wire"
);
assert!(
first_text(&h).contains("Bare Summary"),
"empty LLM response must trigger bare_summary"
);

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
assert!(
evts.iter()
.any(|e| matches!(e, AgentEventPayload::Compacted(_))),
"Compacted event must fire on empty-response fallback"
);
}

#[tokio::test]
async fn compact_extracts_tagged_summary_from_llm_response() {
// Prompt asks the model to wrap output in <summary>...</summary>.
// The store must retain only the inner body — not the analysis
// scratchpad or the tags themselves.
let llm_response =
"<analysis>\nfoo bar drafting\n</analysis>\n<summary>\nFINAL_SUMMARY_TEXT\n</summary>";
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn(llm_response)])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

h.runner.force_compact(None).await.unwrap();

let text = first_text(&h);
assert!(text.contains("FINAL_SUMMARY_TEXT"), "got: {text:?}");
assert!(
!text.contains("foo bar drafting"),
"analysis scratchpad must NOT be persisted, got: {text:?}"
);
assert!(
!text.contains("<summary>") && !text.contains("</summary>"),
"tags must not survive, got: {text:?}"
);
}
143 changes: 143 additions & 0 deletions crates/loopal-runtime/tests/agent_loop/compact_force_e2e_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use loopal_context::ContextBudget;
use loopal_protocol::AgentEventPayload;
use loopal_provider_api::{ContentBlock, Message};
use loopal_test_support::{HarnessBuilder, chunks};

fn tiny_budget() -> ContextBudget {
ContextBudget {
context_window: 500,
system_tokens: 0,
tool_tokens: 0,
output_reserve: 50,
safety_margin: 25,
message_budget: 425,
max_output_tokens: 50,
}
}

fn padded_user_msg(label: &str) -> Message {
Message::user(&format!("{label}: {}", "x".repeat(100)))
}

fn padded_seed(n: usize) -> Vec<Message> {
(0..n)
.map(|i| padded_user_msg(&format!("msg-{i}")))
.collect()
}

#[tokio::test]
async fn manual_compact_reduces_turns() {
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("summary")])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

let before = h.runner.turns.store().turns().len();
h.runner.force_compact(None).await.unwrap();

// boundary turn keeps last user turn; older are dropped via CompactionSummary
// on the boundary. The view drops everything before the boundary turn.
let view_len = h.runner.turns.view().len();
assert!(
view_len < before,
"expected wire to shrink after compact: view={view_len}, before={before}"
);

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
assert!(
evts.iter()
.any(|e| matches!(e, AgentEventPayload::Compacted(_))),
"expected Compacted event"
);
}

#[tokio::test]
async fn compact_event_payload_carries_manual_strategy_label() {
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("summary")])
.messages(padded_seed(15))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

h.runner.force_compact(None).await.unwrap();

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
let stats = evts.iter().find_map(|e| match e {
AgentEventPayload::Compacted(s) => Some(s.clone()),
_ => None,
});
let stats = stats.expect("Compacted event must fire");
assert!(stats.kept > 0);
assert!(stats.removed > 0);
assert!(
stats.strategy.starts_with("manual"),
"expected manual-* strategy, got {}",
stats.strategy
);
}

#[tokio::test]
async fn compact_preserves_most_recent_user_message() {
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("summary")])
.messages(padded_seed(20))
.build()
.await;
h.runner.turns.update_budget(tiny_budget());

h.runner.force_compact(None).await.unwrap();

let last_text = h.runner.turns.view().messages().iter().rev().find_map(|m| {
m.content.iter().find_map(|b| match b {
ContentBlock::Text { text } if text.starts_with("msg-") => Some(text.clone()),
_ => None,
})
});
let text = last_text.expect("expected at least one msg-* in projected view");
assert!(
text.starts_with("msg-19"),
"most recent user turn must survive compact, got: {text:?}"
);
}

#[tokio::test]
async fn force_compact_short_circuits_on_tiny_history() {
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("noop")])
.messages(vec![Message::user("only one")])
.build()
.await;

h.runner.force_compact(None).await.unwrap();

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
let saw_compacted = evts
.iter()
.any(|e| matches!(e, AgentEventPayload::Compacted(_)));
assert!(
!saw_compacted,
"Compacted must not fire when there's nothing to compact"
);
}

#[tokio::test]
async fn force_compact_handles_empty_store() {
let mut h = HarnessBuilder::new()
.calls(vec![chunks::text_turn("noop")])
.messages(vec![])
.build()
.await;

h.runner.force_compact(None).await.unwrap();

let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await;
assert!(
!evts
.iter()
.any(|e| matches!(e, AgentEventPayload::Compacted(_))),
"Compacted must not fire on empty store"
);
}
Loading
Loading