From a1d77387766c8950feff2293faadcd3063e636a5 Mon Sep 17 00:00:00 2001 From: yishuiliunian Date: Wed, 27 May 2026 01:06:19 +0800 Subject: [PATCH] test: restore e2e compaction coverage deleted in 443dd444/3d4111f4/ce82e401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores 32 e2e tests covering production features whose tests were over- zealously dropped during the dual-write retirement. Migrates seed pattern from removed ContextStore::push_* / record_assistant_activity API to TurnStore-based seed via HarnessBuilder::messages + new tool_history helper. Restored coverage (new src/test-support helpers + agent_loop test files): - microcompact_e2e (5): idle scrub / noop on recent / non-scrubbable / all 10 supported tool types / disabled when idle=0 - rehydrate_e2e (9): real Read tool / empty touched / unreadable paths / partial success / total bytes budget / stream event / model-visible partial-failure note / pre-cancel / mid-cancel - compact_force_e2e (5): reduces turns / manual-* strategy label / preserves most-recent user / short-circuits tiny history / empty store - compact_instructions_e2e (3): custom instructions injected / omitted when None / whitespace-only treated as absent - compact_bare_summary_e2e (4): falls back on LLM failure / deterministic outline / empty-response fallback / tagged summary extraction - compact_phases_e2e (2): full phase sequence with rehydrate / skips rehydrate when no files touched - compact_hooks_e2e (3): PreCompact fires on manual /compact / skipped on tiny history / fires on auto check_and_compact - governance_bridge (3): DataPlaneBridge sentinel mock / make_governance_feedback empty handling / multi-line text Also fixes a real regression revealed by rehydrate_partial_failure_appends_ model_visible_note: the partial-failure Text note ("rehydrate partial: N of M ...") was constructed in compact_rehydrate's user message builder but never persisted — CompactionRehydrate carried only file pairs, no note. Add CompactionRehydrate.partial_note: Option and project it in both the wire-format anthropic emitter and the projection layer. Test-support additions: - tool_history::tool_history_turn — builds LlmCall(tool_calls) + ToolBatch Turn pair so tests can seed tool-result history without per-test boilerplate. - tool_history::backdate_activity — uses TurnTracker::with_wire_mut to retroactively age last_step_at, replacing the removed record_assistant_activity API. - tool_history::reopen_for_test — reopens the seeded Complete turn so rehydrate's append_step_record finds an InProgress host. Skipped (genuinely obsolete — original API removed): - e2e_compact_partial_save_test (1): tested save_message failure path; message_store is gone - e2e_compact_resume_test (2): tested messages.jsonl-anchored compact boundary marker survival; turns.jsonl is the only resume source now - e2e_compact_test::auto_compact_on_large_context: covered by new compact_hooks::precompact_hook_fires_on_auto_compact - e2e_compact_edge_test::test_thinking_blocks_stripped: covered by turn_degradation::strips_thinking_from_old_turns_only unit test --- .../src/wire/turn_projection/compaction.rs | 5 +- .../src/anthropic/request_turns/mod.rs | 6 + .../src/agent_loop/compact_rehydrate/mod.rs | 5 + .../compact_bare_summary_e2e_test.rs | 147 +++++++++ .../agent_loop/compact_force_e2e_test.rs | 143 ++++++++ .../agent_loop/compact_hooks_e2e_test.rs | 128 ++++++++ .../compact_instructions_e2e_test.rs | 101 ++++++ .../agent_loop/compact_phases_e2e_test.rs | 121 +++++++ .../tests/agent_loop/microcompact_e2e_test.rs | 177 ++++++++++ crates/loopal-runtime/tests/agent_loop/mod.rs | 7 + .../tests/agent_loop/rehydrate_e2e_test.rs | 309 ++++++++++++++++++ crates/loopal-runtime/tests/suite.rs | 2 + .../tests/suite/governance_bridge_test.rs | 47 +++ crates/loopal-test-support/src/lib.rs | 1 + .../loopal-test-support/src/tool_history.rs | 102 ++++++ crates/loopal-turn/src/step.rs | 4 + 16 files changed, 1304 insertions(+), 1 deletion(-) create mode 100644 crates/loopal-runtime/tests/agent_loop/compact_bare_summary_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/compact_force_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/compact_hooks_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/compact_instructions_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/compact_phases_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/microcompact_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/agent_loop/rehydrate_e2e_test.rs create mode 100644 crates/loopal-runtime/tests/suite/governance_bridge_test.rs create mode 100644 crates/loopal-test-support/src/tool_history.rs diff --git a/crates/loopal-provider-api/src/wire/turn_projection/compaction.rs b/crates/loopal-provider-api/src/wire/turn_projection/compaction.rs index 03a817a5..5a5b4475 100644 --- a/crates/loopal-provider-api/src/wire/turn_projection/compaction.rs +++ b/crates/loopal-provider-api/src/wire/turn_projection/compaction.rs @@ -39,7 +39,7 @@ pub(super) fn project_compaction_rehydrate(r: &CompactionRehydrate) -> Vec = r + let mut user_blocks: Vec = r .files .iter() .map(|f| ContentBlock::ToolResult { @@ -50,6 +50,9 @@ pub(super) fn project_compaction_rehydrate(r: &CompactionRehydrate) -> Vec, r: &CompactionRehydrate) { }] })); } + if let Some(note) = r.partial_note.as_ref() { + out.push(json!({ + "role": "user", + "content": [{"type": "text", "text": note}], + })); + } } #[cfg(test)] diff --git a/crates/loopal-runtime/src/agent_loop/compact_rehydrate/mod.rs b/crates/loopal-runtime/src/agent_loop/compact_rehydrate/mod.rs index bd2fc559..e32a5f72 100644 --- a/crates/loopal-runtime/src/agent_loop/compact_rehydrate/mod.rs +++ b/crates/loopal-runtime/src/agent_loop/compact_rehydrate/mod.rs @@ -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"); diff --git a/crates/loopal-runtime/tests/agent_loop/compact_bare_summary_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/compact_bare_summary_e2e_test.rs new file mode 100644 index 00000000..8d4a7b6c --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/compact_bare_summary_e2e_test.rs @@ -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 { + (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 .... + // The store must retain only the inner body — not the analysis + // scratchpad or the tags themselves. + let llm_response = + "\nfoo bar drafting\n\n\nFINAL_SUMMARY_TEXT\n"; + 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("") && !text.contains(""), + "tags must not survive, got: {text:?}" + ); +} diff --git a/crates/loopal-runtime/tests/agent_loop/compact_force_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/compact_force_e2e_test.rs new file mode 100644 index 00000000..bb95ce8a --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/compact_force_e2e_test.rs @@ -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 { + (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" + ); +} diff --git a/crates/loopal-runtime/tests/agent_loop/compact_hooks_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/compact_hooks_e2e_test.rs new file mode 100644 index 00000000..903b5362 --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/compact_hooks_e2e_test.rs @@ -0,0 +1,128 @@ +#![cfg(unix)] + +use std::time::Duration; + +use loopal_config::{HookConfig, HookEvent}; +use loopal_context::ContextBudget; +use loopal_provider_api::Message; +use loopal_test_support::{HarnessBuilder, HookFixture, 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(label: &str) -> Message { + // cl100k_base compresses uniform runs to nothing; varied bytes give a + // realistic token count. + let body: String = (0..100u8).map(|i| char::from(b'a' + (i % 26))).collect(); + Message::user(&format!("{label}: {body}")) +} + +fn build_hook(script: &std::path::Path) -> HookConfig { + HookConfig { + event: HookEvent::PreCompact, + command: script.to_str().unwrap().to_string(), + tool_filter: None, + timeout_ms: 5000, + hook_type: Default::default(), + url: None, + headers: Default::default(), + prompt: None, + model: None, + condition: None, + id: None, + } +} + +#[tokio::test] +async fn precompact_hook_fires_on_manual_compact() { + let mut hook_fx = HookFixture::new(); + let (script, marker) = hook_fx.create_echo_hook("manual_compact_fired"); + + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .hooks(vec![build_hook(&script)]) + .messages((0..6).map(|i| Message::user(&format!("m{i}"))).collect()) + .build() + .await; + + h.runner.force_compact(None).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + assert!( + marker.exists(), + "PreCompact hook marker must exist after force_compact, at {}", + marker.display() + ); + let content = std::fs::read_to_string(&marker).unwrap(); + assert!( + content.contains("manual_compact_fired"), + "marker should hold 'manual_compact_fired', got: {content}" + ); +} + +#[tokio::test] +async fn precompact_hook_skipped_when_nothing_to_compact() { + let mut hook_fx = HookFixture::new(); + let (script, marker) = hook_fx.create_echo_hook("should_not_fire"); + + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .hooks(vec![build_hook(&script)]) + .messages(vec![Message::user("single")]) + .build() + .await; + + h.runner.force_compact(None).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + assert!( + !marker.exists(), + "PreCompact must not fire when conversation is too short" + ); +} + +#[tokio::test] +async fn precompact_hook_fires_on_auto_compact() { + // Auto path through check_and_compact must fire PreCompact symmetric + // to manual /compact. + let mut hook_fx = HookFixture::new(); + let (script, marker) = hook_fx.create_echo_hook("auto_compact_fired"); + + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary")]) + .hooks(vec![build_hook(&script)]) + .messages((0..30).map(|i| padded(&format!("seed-{i}"))).collect()) + .build() + .await; + h.runner.turns.update_budget(tiny_budget()); + + assert!( + h.runner.turns.view().needs_summarization(), + "precondition: store must already need compaction; effective_tokens={}", + h.runner.turns.view().effective_tokens() + ); + + let cancel = tokio_util::sync::CancellationToken::new(); + h.runner.check_and_compact(&cancel).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + assert!( + marker.exists(), + "PreCompact marker must exist after auto-compact, at {}", + marker.display() + ); + let content = std::fs::read_to_string(&marker).unwrap(); + assert!( + content.contains("auto_compact_fired"), + "marker should hold 'auto_compact_fired', got: {content}" + ); +} diff --git a/crates/loopal-runtime/tests/agent_loop/compact_instructions_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/compact_instructions_e2e_test.rs new file mode 100644 index 00000000..9a1068d2 --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/compact_instructions_e2e_test.rs @@ -0,0 +1,101 @@ +use loopal_context::ContextBudget; +use loopal_provider_api::Message; +use loopal_test_support::{HarnessBuilder, chunks}; +use loopal_turn::{Turn, TurnTrigger}; + +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 { + (0..n) + .map(|i| Message::user(&format!("m{i}: {}", "x".repeat(100)))) + .collect() +} + +// smart_compact_llm builds a single-turn ChatParams; user prompt is in trigger. +fn first_user_text(turns: &[Turn]) -> String { + turns + .iter() + .find_map(|t| match &t.trigger { + TurnTrigger::UserInput { content, .. } => Some(content.clone()), + _ => None, + }) + .unwrap_or_default() +} + +#[tokio::test] +async fn force_compact_injects_custom_instructions_into_prompt() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .messages(padded_seed(6)) + .build() + .await; + h.runner.turns.update_budget(tiny_budget()); + + h.runner + .force_compact(Some("preserve test repro steps".into())) + .await + .unwrap(); + + let calls = h.recorded_messages.lock().unwrap(); + let first_call = calls.first().expect("summarization LLM call missing"); + let prompt = first_user_text(first_call); + + assert!(prompt.contains("")); + assert!(prompt.contains("preserve test repro steps")); + assert!(prompt.contains("")); +} + +#[tokio::test] +async fn force_compact_omits_custom_instructions_when_none() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .messages(padded_seed(6)) + .build() + .await; + h.runner.turns.update_budget(tiny_budget()); + + h.runner.force_compact(None).await.unwrap(); + + let calls = h.recorded_messages.lock().unwrap(); + let first_call = calls.first().expect("summarization LLM call missing"); + let prompt = first_user_text(first_call); + + assert!( + !prompt.contains(""), + "prompt must not emit empty tag, got: {prompt:?}" + ); +} + +#[tokio::test] +async fn force_compact_treats_whitespace_instructions_as_absent() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .messages(padded_seed(6)) + .build() + .await; + h.runner.turns.update_budget(tiny_budget()); + + h.runner + .force_compact(Some(" \n ".into())) + .await + .unwrap(); + + let calls = h.recorded_messages.lock().unwrap(); + let first_call = calls.first().expect("summarization LLM call missing"); + let prompt = first_user_text(first_call); + + assert!( + !prompt.contains(""), + "whitespace-only instructions must collapse to absent, got: {prompt:?}" + ); +} diff --git a/crates/loopal-runtime/tests/agent_loop/compact_phases_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/compact_phases_e2e_test.rs new file mode 100644 index 00000000..7dd79204 --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/compact_phases_e2e_test.rs @@ -0,0 +1,121 @@ +use loopal_context::ContextBudget; +use loopal_protocol::{AgentEventPayload, CompactPhase}; +use loopal_provider_api::Message; +use loopal_test_support::tool_history::{ToolStep, tool_history_turn}; +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 { + (0..n) + .map(|i| Message::user(&format!("m{i}: {}", "x".repeat(100)))) + .collect() +} + +fn phase_label(p: &CompactPhase) -> &'static str { + match p { + CompactPhase::Microcompact => "Microcompact", + CompactPhase::Summarize => "Summarize", + CompactPhase::Rehydrate => "Rehydrate", + CompactPhase::Done => "Done", + } +} + +#[tokio::test] +async fn compact_emits_full_phase_sequence_with_rehydrate() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .messages(padded_seed(6)) + .build() + .await; + h.runner.turns.update_budget(tiny_budget()); + + let touched_file = h.fixture.create_file("touched.txt", "rehydrated body\n"); + // Seed a Read tool history so the touched_files middleware records the + // file and rehydrate picks it up. + h.runner.seed_test_turns(vec![tool_history_turn( + "go", + vec![ToolStep::done( + "Read", + "t1", + &format!("file_path: {}", touched_file.to_string_lossy()), + )], + )]); + + h.runner.force_compact(None).await.unwrap(); + + let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await; + let positions: Vec<(usize, &str)> = evts + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + AgentEventPayload::CompactProgress { phase, .. } => Some((i, phase_label(phase))), + AgentEventPayload::Compacted(_) => Some((i, "Compacted")), + _ => None, + }) + .collect(); + + let find = |label: &str| positions.iter().find(|(_, p)| *p == label).map(|(i, _)| *i); + + let summarize = find("Summarize").expect("Summarize phase missing"); + let compacted = find("Compacted").expect("Compacted event missing"); + let done = find("Done").expect("Done phase missing"); + + assert!( + summarize < compacted, + "Summarize must precede Compacted: {positions:?}" + ); + assert!( + compacted < done, + "Compacted must precede Done: {positions:?}" + ); +} + +#[tokio::test] +async fn compact_skips_rehydrate_when_no_files_touched() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("compact-summary-body")]) + .messages(padded_seed(6)) + .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 saw_rehydrate = evts.iter().any(|e| { + matches!( + e, + AgentEventPayload::CompactProgress { + phase: CompactPhase::Rehydrate, + .. + } + ) + }); + let saw_done = evts.iter().any(|e| { + matches!( + e, + AgentEventPayload::CompactProgress { + phase: CompactPhase::Done, + .. + } + ) + }); + let saw_compacted = evts + .iter() + .any(|e| matches!(e, AgentEventPayload::Compacted(_))); + + assert!(!saw_rehydrate, "Rehydrate must be skipped: {evts:?}"); + assert!(saw_compacted, "Compacted still required: {evts:?}"); + assert!(saw_done, "Done still required: {evts:?}"); +} diff --git a/crates/loopal-runtime/tests/agent_loop/microcompact_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/microcompact_e2e_test.rs new file mode 100644 index 00000000..0fb99e4b --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/microcompact_e2e_test.rs @@ -0,0 +1,177 @@ +use std::time::Duration; + +use loopal_protocol::{AgentEventPayload, CompactPhase}; +use loopal_test_support::tool_history::{ToolStep, backdate_activity, tool_history_turn}; +use loopal_test_support::{HarnessBuilder, chunks}; +use loopal_turn::{ToolExecState, TurnStep}; + +const CLEARED_MARKER: &str = "[Old tool result content cleared after idle timeout]"; + +fn collect_tool_result_bodies(runner: &loopal_runtime::agent_loop::AgentLoopRunner) -> Vec { + let mut out = Vec::new(); + for turn in runner.turns.store().turns() { + for step in &turn.body.steps { + let TurnStep::ToolBatch(batch) = step else { + continue; + }; + for item in &batch.items { + if let ToolExecState::Done(r) = &item.state { + out.push(r.content.clone()); + } + } + } + } + out +} + +async fn drain_microcompact_event( + rx: &mut tokio::sync::mpsc::Receiver, +) -> bool { + let evts = loopal_test_support::events::drain_pending(rx).await; + evts.iter().any(|e| { + matches!( + e, + AgentEventPayload::CompactProgress { + phase: CompactPhase::Microcompact, + .. + } + ) + }) +} + +#[tokio::test] +async fn microcompact_scrubs_idle_tool_results_e2e() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + + h.runner.params.config.microcompact_idle = Duration::from_secs(60); + h.runner.seed_test_turns(vec![tool_history_turn( + "go", + vec![ + ToolStep::done("Read", "u1", "file contents A"), + ToolStep::done("Bash", "u2", "shell output B"), + ], + )]); + backdate_activity(&mut h.runner, 120); + + h.runner.check_and_microcompact().await.unwrap(); + + let bodies = collect_tool_result_bodies(&h.runner); + assert_eq!(bodies.len(), 2); + assert!( + bodies.iter().all(|b| b == CLEARED_MARKER), + "all tool results scrubbed, got: {bodies:?}" + ); + assert!( + drain_microcompact_event(&mut h.event_rx).await, + "expected CompactProgress(Microcompact) event" + ); +} + +#[tokio::test] +async fn microcompact_noop_when_recent_activity_e2e() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + + h.runner.params.config.microcompact_idle = Duration::from_secs(60); + h.runner.seed_test_turns(vec![tool_history_turn( + "go", + vec![ToolStep::done("Read", "u1", "stays as-is")], + )]); + + h.runner.check_and_microcompact().await.unwrap(); + + assert_eq!(collect_tool_result_bodies(&h.runner), vec!["stays as-is"]); + assert!( + !drain_microcompact_event(&mut h.event_rx).await, + "no event should fire inside idle window" + ); +} + +#[tokio::test] +async fn microcompact_preserves_non_scrubbable_tools_e2e() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + + h.runner.params.config.microcompact_idle = Duration::from_secs(60); + h.runner.seed_test_turns(vec![tool_history_turn( + "go", + vec![ToolStep::done("Plan", "u1", "deep deliberation")], + )]); + backdate_activity(&mut h.runner, 120); + + h.runner.check_and_microcompact().await.unwrap(); + + assert_eq!( + collect_tool_result_bodies(&h.runner), + vec!["deep deliberation"] + ); +} + +#[tokio::test] +async fn microcompact_scrubs_all_supported_tool_types() { + let tools = [ + "Read", + "Write", + "Edit", + "MultiEdit", + "Bash", + "Grep", + "Glob", + "WebFetch", + "WebSearch", + "Ls", + ]; + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + h.runner.params.config.microcompact_idle = Duration::from_secs(60); + + let steps: Vec = tools + .iter() + .enumerate() + .map(|(i, name)| ToolStep::done(name, &format!("u{i}"), &format!("{name} output body"))) + .collect(); + h.runner + .seed_test_turns(vec![tool_history_turn("go", steps)]); + backdate_activity(&mut h.runner, 120); + + h.runner.check_and_microcompact().await.unwrap(); + + let bodies = collect_tool_result_bodies(&h.runner); + assert_eq!(bodies.len(), tools.len()); + assert!( + bodies.iter().all(|b| b == CLEARED_MARKER), + "every scrubbable tool body must collapse to CLEARED_MARKER; got: {bodies:?}" + ); +} + +#[tokio::test] +async fn microcompact_disabled_when_idle_duration_is_zero() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + + h.runner.params.config.microcompact_idle = Duration::ZERO; + h.runner.seed_test_turns(vec![tool_history_turn( + "go", + vec![ToolStep::done("Read", "u1", "stays as-is")], + )]); + backdate_activity(&mut h.runner, 86_400); + + h.runner.check_and_microcompact().await.unwrap(); + + assert_eq!(collect_tool_result_bodies(&h.runner), vec!["stays as-is"]); + assert!( + !drain_microcompact_event(&mut h.event_rx).await, + "idle=0 must disable microcompact" + ); +} diff --git a/crates/loopal-runtime/tests/agent_loop/mod.rs b/crates/loopal-runtime/tests/agent_loop/mod.rs index a2504106..7ba78956 100644 --- a/crates/loopal-runtime/tests/agent_loop/mod.rs +++ b/crates/loopal-runtime/tests/agent_loop/mod.rs @@ -57,6 +57,11 @@ pub fn make_test_budget() -> ContextBudget { mod ask_user_schema_err_test; mod auto_continue_edge_test; mod auto_continue_test; +mod compact_bare_summary_e2e_test; +mod compact_force_e2e_test; +mod compact_hooks_e2e_test; +mod compact_instructions_e2e_test; +mod compact_phases_e2e_test; mod compaction_run_e2e_test; mod cron_e2e_test; mod degeneration_e2e_test; @@ -78,7 +83,9 @@ mod input_test; mod integration_test; mod llm_test; mod llm_truncation_test; +mod microcompact_e2e_test; pub mod mock_provider; +mod rehydrate_e2e_test; pub use mock_provider::make_runner_with_mock_provider; mod cancel_test; mod context_budget_test; diff --git a/crates/loopal-runtime/tests/agent_loop/rehydrate_e2e_test.rs b/crates/loopal-runtime/tests/agent_loop/rehydrate_e2e_test.rs new file mode 100644 index 00000000..eb10855d --- /dev/null +++ b/crates/loopal-runtime/tests/agent_loop/rehydrate_e2e_test.rs @@ -0,0 +1,309 @@ +use loopal_context::compact_config::REHYDRATE_TOTAL_BYTES; +use loopal_context::middleware::touched_files::TouchedFile; +use loopal_protocol::AgentEventPayload; +use loopal_provider_api::{ContentBlock, MessageRole}; +use loopal_test_support::tool_history::reopen_for_test; +use loopal_test_support::{HarnessBuilder, chunks}; +use tokio_util::sync::CancellationToken; + +fn touched_at(path: &std::path::Path, mutated: bool, idx: usize) -> TouchedFile { + TouchedFile { + path: path.to_string_lossy().into(), + mutated, + last_seen_msg_idx: idx, + } +} + +#[tokio::test] +async fn rehydrate_reads_files_via_real_read_tool() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let a = h.fixture.create_file("a.txt", "content of A\n"); + let b = h.fixture.create_file("nested/b.txt", "content of B\n"); + let touched = vec![touched_at(&a, false, 0), touched_at(&b, true, 1)]; + + let before = h.runner.turns.view().len(); + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + + assert_eq!(stats.files_attempted, 2); + assert_eq!(stats.files_succeeded, 2); + assert!(stats.bytes_injected >= "content of A\n".len() + "content of B\n".len()); + + let msgs = h.runner.turns.view().messages(); + assert_eq!( + msgs.len(), + before + 2, + "+1 assistant ToolUse, +1 user ToolResult" + ); + + let assistant = msgs.iter().rev().nth(1).expect("assistant msg"); + assert_eq!(assistant.role, MessageRole::Assistant); + let tool_use_ids: Vec<&str> = assistant + .content + .iter() + .filter_map(|b| match b { + ContentBlock::ToolUse { id, name, .. } if name == "Read" => Some(id.as_str()), + _ => None, + }) + .collect(); + assert_eq!(tool_use_ids.len(), 2); + + let user = msgs.last().expect("user tool_results msg"); + assert_eq!(user.role, MessageRole::User); + let bodies: Vec<&str> = user + .content + .iter() + .filter_map(|b| match b { + ContentBlock::ToolResult { + tool_use_id, + content, + .. + } if tool_use_ids.contains(&tool_use_id.as_str()) => Some(content.as_str()), + _ => None, + }) + .collect(); + assert_eq!(bodies.len(), 2); + assert!(bodies.iter().any(|b| b.contains("content of A"))); + assert!(bodies.iter().any(|b| b.contains("content of B"))); +} + +#[tokio::test] +async fn rehydrate_noop_on_empty_touched() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let before = h.runner.turns.view().len(); + let stats = h + .runner + .compact_rehydrate(&[], &CancellationToken::new()) + .await; + + assert_eq!(stats.files_attempted, 0); + assert_eq!(stats.files_succeeded, 0); + assert_eq!(h.runner.turns.view().len(), before); +} + +#[tokio::test] +async fn rehydrate_skips_unreadable_paths() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let touched = vec![ + touched_at(&h.fixture.path().join("does-not-exist.txt"), false, 0), + touched_at(&h.fixture.path().join("also-missing.txt"), false, 1), + ]; + + let before = h.runner.turns.view().len(); + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + + assert_eq!(stats.files_attempted, 2); + assert_eq!(stats.files_succeeded, 0); + assert_eq!(stats.bytes_injected, 0); + assert_eq!( + h.runner.turns.view().len(), + before, + "no orphan ToolUse/ToolResult when every read fails" + ); +} + +#[tokio::test] +async fn rehydrate_handles_partial_success() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let real = h.fixture.create_file("real.txt", "real body\n"); + let touched = vec![ + touched_at(&real, false, 0), + touched_at(&h.fixture.path().join("ghost.txt"), false, 1), + touched_at(&h.fixture.path().join("phantom.txt"), true, 2), + ]; + + let before = h.runner.turns.view().len(); + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + + assert_eq!(stats.files_attempted, 3); + assert_eq!(stats.files_succeeded, 1); + assert!(stats.bytes_injected >= "real body\n".len()); + + let msgs = h.runner.turns.view().messages(); + assert_eq!(msgs.len(), before + 2, "1 Assistant + 1 User"); + let tool_use_count = msgs + .iter() + .flat_map(|m| m.content.iter()) + .filter(|b| matches!(b, ContentBlock::ToolUse { name, .. } if name == "Read")) + .count(); + let tool_result_count = msgs + .iter() + .flat_map(|m| m.content.iter()) + .filter(|b| matches!(b, ContentBlock::ToolResult { .. })) + .count(); + assert_eq!(tool_use_count, 1); + assert_eq!(tool_result_count, 1, "pair invariant intact"); +} + +#[tokio::test] +async fn rehydrate_respects_total_bytes_budget() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let body = "X".repeat(12_000); + let touched: Vec = (0..6) + .map(|i| { + let p = h.fixture.create_file(&format!("big-{i}.txt"), &body); + touched_at(&p, false, i) + }) + .collect(); + + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + + assert!( + stats.bytes_injected <= REHYDRATE_TOTAL_BYTES, + "injected {} > cap {REHYDRATE_TOTAL_BYTES}", + stats.bytes_injected + ); + assert!(stats.files_succeeded >= 1, "at least one file must fit"); +} + +#[tokio::test] +async fn rehydrate_emits_summary_stream_event() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let real = h.fixture.create_file("ok.txt", "body\n"); + let touched = vec![touched_at(&real, false, 0)]; + + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + assert_eq!(stats.files_succeeded, 1); + + let evts = loopal_test_support::events::drain_pending(&mut h.event_rx).await; + let stream_text = evts.iter().find_map(|e| match e { + AgentEventPayload::Stream { text } if text.contains("rehydrated") => Some(text.clone()), + _ => None, + }); + let text = stream_text.expect("rehydrate Stream event must fire"); + assert!(text.contains("rehydrated 1 files")); + assert!(text.contains("bytes")); +} + +#[tokio::test] +async fn rehydrate_partial_failure_appends_model_visible_note() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let real = h.fixture.create_file("only.txt", "ok\n"); + let touched = vec![ + touched_at(&real, false, 0), + touched_at(&h.fixture.path().join("missing-1.txt"), false, 1), + touched_at(&h.fixture.path().join("missing-2.txt"), false, 2), + ]; + + let stats = h + .runner + .compact_rehydrate(&touched, &CancellationToken::new()) + .await; + + assert_eq!(stats.files_attempted, 3); + assert_eq!(stats.files_succeeded, 1); + + let msgs = h.runner.turns.view().messages(); + let user = msgs.last().expect("user message"); + let note = user + .content + .iter() + .find_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .expect("partial-failure note must be present"); + assert!( + note.contains("rehydrate partial: 2 of 3"), + "note must spell out skipped/attempted, got: {note:?}" + ); +} + +#[tokio::test] +async fn rehydrate_pre_cancelled_token_skips_persist() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let real = h.fixture.create_file("victim.txt", "should not be read\n"); + let touched = vec![touched_at(&real, false, 0)]; + + let before = h.runner.turns.view().len(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let stats = h.runner.compact_rehydrate(&touched, &cancel).await; + + assert!(stats.cancelled); + assert_eq!(stats.files_succeeded, 0); + assert_eq!(stats.bytes_injected, 0); + assert_eq!(h.runner.turns.view().len(), before); +} + +#[tokio::test] +async fn rehydrate_cancel_during_reads_leaves_store_untouched() { + let mut h = HarnessBuilder::new() + .calls(vec![chunks::text_turn("noop")]) + .build() + .await; + reopen_for_test(&mut h.runner); + + let touched: Vec = (0..5) + .map(|i| { + let p = h.fixture.create_file(&format!("f{i}.txt"), "body\n"); + touched_at(&p, false, i) + }) + .collect(); + + let before = h.runner.turns.view().len(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let stats = h.runner.compact_rehydrate(&touched, &cancel).await; + + assert!(stats.cancelled); + assert_eq!( + h.runner.turns.view().len(), + before, + "store must remain pristine — no orphan ToolUse from aborted rehydrate" + ); +} diff --git a/crates/loopal-runtime/tests/suite.rs b/crates/loopal-runtime/tests/suite.rs index c1adf48f..fc087772 100644 --- a/crates/loopal-runtime/tests/suite.rs +++ b/crates/loopal-runtime/tests/suite.rs @@ -41,6 +41,8 @@ mod goal_session_reopen_test; mod goal_session_support; #[path = "suite/goal_session_test.rs"] mod goal_session_test; +#[path = "suite/governance_bridge_test.rs"] +mod governance_bridge_test; #[path = "suite/hydrate_test.rs"] mod hydrate_test; #[path = "suite/loop_detector_edge_test.rs"] diff --git a/crates/loopal-runtime/tests/suite/governance_bridge_test.rs b/crates/loopal-runtime/tests/suite/governance_bridge_test.rs new file mode 100644 index 00000000..df0bb8ef --- /dev/null +++ b/crates/loopal-runtime/tests/suite/governance_bridge_test.rs @@ -0,0 +1,47 @@ +use loopal_provider_api::{ContentBlock, Message, MessageRole}; +use loopal_runtime::agent_loop::governance::{DataPlaneBridge, make_governance_feedback}; + +#[derive(Default)] +struct CapturingBridge { + notes: Vec, +} + +impl DataPlaneBridge for CapturingBridge { + fn push_system_note(&mut self, msg: Message) { + self.notes.push(msg); + } +} + +#[test] +fn mock_bridge_captures_system_note_dispatch() { + let mut bridge = CapturingBridge::default(); + let note = make_governance_feedback("stop retrying").unwrap(); + bridge.push_system_note(note); + + assert_eq!(bridge.notes.len(), 1); + let ContentBlock::Text { text } = &bridge.notes[0].content[0] else { + panic!("expected Text block"); + }; + assert_eq!(text, "stop retrying"); + assert_eq!(bridge.notes[0].role, MessageRole::User); + assert_eq!( + bridge.notes[0].origin, + Some(loopal_provider_api::MessageOrigin::GovernanceFeedback) + ); +} + +#[test] +fn make_governance_feedback_empty_yields_no_note() { + assert!(make_governance_feedback("").is_none()); +} + +#[test] +fn make_governance_feedback_carries_multiline_text() { + let msg = make_governance_feedback("line 1\nline 2\nline 3").unwrap(); + let ContentBlock::Text { text } = &msg.content[0] else { + panic!("expected Text"); + }; + assert!(text.contains("line 1")); + assert!(text.contains("line 3")); + assert_eq!(text.lines().count(), 3); +} diff --git a/crates/loopal-test-support/src/lib.rs b/crates/loopal-test-support/src/lib.rs index c918215b..f804739c 100644 --- a/crates/loopal-test-support/src/lib.rs +++ b/crates/loopal-test-support/src/lib.rs @@ -17,6 +17,7 @@ pub mod mcp_mock; pub mod mock_provider; pub mod scenarios; pub mod seed_history; +pub mod tool_history; mod wiring; /// In-memory duplex transport pair — re-export of `loopal_ipc::duplex_pair`. diff --git a/crates/loopal-test-support/src/tool_history.rs b/crates/loopal-test-support/src/tool_history.rs new file mode 100644 index 00000000..012e0fbe --- /dev/null +++ b/crates/loopal-test-support/src/tool_history.rs @@ -0,0 +1,102 @@ +use loopal_turn::{ + AssistantOutput, OrderedToolBatch, StopReason, ToolBatchItem, ToolCall, ToolCallId, + ToolExecState, ToolResult, Turn, TurnOutcome, TurnStep, TurnTrigger, +}; + +pub struct ToolStep { + pub tool_use_id: String, + pub tool_name: String, + pub result_body: String, + pub state: ToolExecState, +} + +impl ToolStep { + pub fn done(tool: &str, id: &str, body: &str) -> Self { + Self { + tool_use_id: id.into(), + tool_name: tool.into(), + result_body: body.into(), + state: ToolExecState::Done(ToolResult { + content: body.into(), + images: Vec::new(), + is_error: false, + }), + } + } + + pub fn cancelled(tool: &str, id: &str, cause: loopal_turn::CancelCause) -> Self { + Self { + tool_use_id: id.into(), + tool_name: tool.into(), + result_body: String::new(), + state: ToolExecState::Cancelled(cause), + } + } +} + +pub fn tool_history_turn(trigger_content: &str, steps: Vec) -> Turn { + let mut turn = Turn::new(TurnTrigger::UserInput { + envelope_id: String::new(), + content: trigger_content.into(), + images: Vec::new(), + }); + let tool_calls: Vec = steps + .iter() + .map(|s| ToolCall { + id: ToolCallId::new(&s.tool_use_id), + name: s.tool_name.clone(), + input: serde_json::json!({}), + }) + .collect(); + turn.body.steps.push(TurnStep::LlmCall { + model: "test".into(), + response: AssistantOutput { + thinking: None, + text_blocks: vec![], + tool_calls: tool_calls.clone(), + server_blocks: vec![], + stop_reason: StopReason::ToolUse, + }, + }); + let items: Vec = steps + .into_iter() + .map(|s| ToolBatchItem { + call: ToolCall { + id: ToolCallId::new(&s.tool_use_id), + name: s.tool_name, + input: serde_json::json!({}), + }, + state: s.state, + }) + .collect(); + turn.body + .steps + .push(TurnStep::ToolBatch(OrderedToolBatch { items })); + turn.outcome = TurnOutcome::Complete; + turn +} + +/// Backdate every seeded turn's `last_step_at` so `last_assistant_activity_at` +/// reports the supplied stale instant. Used by microcompact e2e tests to +/// simulate the idle-timeout firing without sleeping wall-clock time. +pub fn backdate_activity( + runner: &mut loopal_runtime::agent_loop::AgentLoopRunner, + seconds_ago: i64, +) { + runner.turns.with_wire_mut(|turns| { + let stale = chrono::Utc::now() - chrono::Duration::seconds(seconds_ago); + for t in turns.iter_mut() { + t.last_step_at = Some(stale); + } + }); +} + +/// Reopen the most recent Complete turn so it accepts further step appends. +/// Used by tests that exercise functions (rehydrate, compaction-summary) +/// which require an InProgress host turn to write into. +pub fn reopen_for_test(runner: &mut loopal_runtime::agent_loop::AgentLoopRunner) { + runner + .turns + .reopen_last_completed_turn() + .expect("seeded store must have at least one Complete turn to reopen"); +} diff --git a/crates/loopal-turn/src/step.rs b/crates/loopal-turn/src/step.rs index d05ee531..e8d9afb5 100644 --- a/crates/loopal-turn/src/step.rs +++ b/crates/loopal-turn/src/step.rs @@ -90,6 +90,10 @@ pub struct CompactionSummary { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompactionRehydrate { pub files: Vec, + // Set when rehydrate finished with files_succeeded < files_attempted — the + // model needs to know which files dropped out so it re-Reads them on demand. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub partial_note: Option, } #[derive(Debug, Clone, Serialize, Deserialize)]