Skip to main content

lash_plugin_rolling_history/
lib.rs

1//! Default rolling-history plugin.
2//!
3//! Owns the compaction strategy that used to live in
4//! `crates/lash/src/session_model/context.rs`: per-turn pruning of old
5//! images and summary insertion, plus persistent history rewrites for
6//! manual compaction, overflow recovery, and window-shrink events.
7//!
8//! Registered as a default plugin by
9//! the first-party default tool bundles from `lash-standard-plugins`,
10//! so standard lash sessions pick it up automatically.
11
12use std::collections::HashMap;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16
17use lash_core::PreparedContext;
18use lash_core::plugin::{
19    HistoryError, HistoryRewriter, HistoryState, PluginError, PluginFactory, PluginOptions,
20    PluginRegistrar, PluginSessionContext, RewriteContext, RewriteTrigger, SessionContextSurface,
21    SessionCreateRequest, SessionPlugin, SessionStartPoint, TurnContextTransform,
22    TurnTransformContext,
23};
24use lash_core::{
25    InputItem, Message, MessageOrigin, MessageRole, Part, PartKind, PromptUsage, SessionSnapshot,
26    TurnInput,
27};
28
29const PRUNE_RECENT_USER_TURNS: usize = 2;
30const COMPACTION_BUFFER_TOKENS: usize = 20_000;
31const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
32const PRUNE_CONTEXT_THRESHOLD: f64 = 0.6;
33/// Marker `plugin_id` stamped on compaction summary messages so the
34/// history pipeline can recognize them on subsequent turns.
35pub(crate) const ROLLING_HISTORY_PLUGIN_ID: &str = "rolling_history";
36const COMPACTION_SUMMARY_TITLE: &str = "Compaction summary:";
37const COMPACTION_PROMPT: &str = "Provide a detailed summary of the conversation above so a later session can continue the work without the full history.\n\nUse this template:\n---\n## Goal\n[What is the user trying to accomplish?]\n\n## Instructions\n- [Relevant instructions or constraints]\n\n## Discoveries\n[Important findings, failures, or decisions]\n\n## Accomplished\n[What is done, what is in progress, what remains]\n\n## Relevant files / directories\n[List important files or directories]\n---";
38const PRUNED_IMAGE_PLACEHOLDER: &str = "[Image omitted from older context]";
39const COMPACTED_IMAGE_PLACEHOLDER: &str = "[Image omitted during compaction]";
40
41#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct RollingHistoryConfig;
43
44fn compaction_update_prompt(previous_summary: &str) -> String {
45    format!(
46        "A previous compaction summary exists (shown below). Update it with information from the conversation above.\n\n\
47         Rules:\n\
48         - PRESERVE all existing information from the previous summary\n\
49         - ADD new progress, decisions, and context from the new messages\n\
50         - Move items from in-progress to done where applicable\n\
51         - PRESERVE exact file paths, function names, and error messages\n\n\
52         Previous summary:\n{previous_summary}\n\n\
53         Use this template:\n---\n\
54         ## Goal\n[What is the user trying to accomplish?]\n\n\
55         ## Instructions\n- [Relevant instructions or constraints]\n\n\
56         ## Discoveries\n[Important findings, failures, or decisions]\n\n\
57         ## Accomplished\n[What is done, what is in progress, what remains]\n\n\
58         ## Relevant files / directories\n[List important files or directories]\n---"
59    )
60}
61
62fn with_instructions(base: &str, instructions: Option<&str>) -> String {
63    match instructions {
64        Some(text) if !text.trim().is_empty() => {
65            format!("{base}\n\nAdditional focus:\n{}\n", text.trim())
66        }
67        _ => base.to_string(),
68    }
69}
70
71fn leading_system_prefix_len(msgs: &[Message]) -> usize {
72    msgs.iter()
73        .take_while(|msg| msg.role == MessageRole::System)
74        .count()
75}
76
77fn approx_token_count(text: &str) -> usize {
78    text.len().div_ceil(4)
79}
80
81fn strip_image_attachment(part: &mut Part, placeholder: &str) -> bool {
82    if !matches!(part.kind, PartKind::Image) || part.attachment.is_none() {
83        return false;
84    }
85    part.attachment = None;
86    part.content = placeholder.to_string();
87    true
88}
89
90fn prune_old_images(messages: &mut [Message]) -> bool {
91    let mut changed = false;
92    let mut recent_user_turns = 0usize;
93
94    'scan: for msg_idx in (0..messages.len()).rev() {
95        if is_compaction_summary_message(&messages[msg_idx]) {
96            break 'scan;
97        }
98        if messages[msg_idx].role == MessageRole::User {
99            recent_user_turns += 1;
100        }
101        if recent_user_turns < PRUNE_RECENT_USER_TURNS {
102            continue;
103        }
104        for part in std::sync::Arc::make_mut(&mut messages[msg_idx].parts).iter_mut() {
105            changed |= strip_image_attachment(part, PRUNED_IMAGE_PLACEHOLDER);
106        }
107    }
108
109    changed
110}
111
112fn strip_all_image_attachments(messages: &mut [Message], placeholder: &str) -> bool {
113    let mut changed = false;
114    for message in messages {
115        for part in std::sync::Arc::make_mut(&mut message.parts).iter_mut() {
116            changed |= strip_image_attachment(part, placeholder);
117        }
118    }
119    changed
120}
121
122fn is_compaction_summary_message(message: &Message) -> bool {
123    matches!(
124        message.origin,
125        Some(MessageOrigin::Plugin { ref plugin_id, .. }) if plugin_id == ROLLING_HISTORY_PLUGIN_ID
126    )
127}
128
129fn latest_user_index(messages: &[Message]) -> Option<usize> {
130    messages
131        .iter()
132        .rposition(|message| matches!(message.role, MessageRole::User))
133}
134
135/// Walk backwards from the end keeping ~`COMPACTION_KEEP_RECENT_TOKENS` worth of messages.
136/// Returns the index of the first message in the "keep" region — everything before it gets
137/// summarized.  The cut always lands on a user-message boundary so we never split a turn.
138fn find_compaction_cut_point(messages: &[Message], prefix_len: usize) -> usize {
139    let start = messages[prefix_len..]
140        .iter()
141        .rposition(is_compaction_summary_message)
142        .map(|i| prefix_len + i + 1)
143        .unwrap_or(prefix_len);
144
145    let mut accumulated = 0usize;
146    for idx in (start..messages.len()).rev() {
147        for part in messages[idx].parts.iter() {
148            accumulated += approx_token_count(&part.content);
149            if part.attachment.is_some() {
150                accumulated += 1200; // approximate image token cost
151            }
152        }
153        if accumulated >= COMPACTION_KEEP_RECENT_TOKENS && messages[idx].role == MessageRole::User {
154            return idx;
155        }
156    }
157    latest_user_index(messages).unwrap_or(messages.len())
158}
159
160fn pruning_needed(prompt_usage: Option<&PromptUsage>, max_context_tokens: Option<usize>) -> bool {
161    let Some(usage) = prompt_usage else {
162        return false;
163    };
164    let Some(max_context) = max_context_tokens else {
165        return false;
166    };
167    if max_context == 0 {
168        return false;
169    }
170    (usage.context_budget_tokens as f64 / max_context as f64) >= PRUNE_CONTEXT_THRESHOLD
171}
172
173fn extract_previous_summary(messages: &[Message]) -> Option<String> {
174    messages.iter().rev().find_map(|m| {
175        if !is_compaction_summary_message(m) {
176            return None;
177        }
178        m.parts.first().map(|p| {
179            p.content
180                .strip_prefix(COMPACTION_SUMMARY_TITLE)
181                .unwrap_or(&p.content)
182                .trim()
183                .to_string()
184        })
185    })
186}
187
188fn compaction_needed(
189    prompt_usage: Option<&PromptUsage>,
190    max_context_tokens: Option<usize>,
191) -> bool {
192    let Some(usage) = prompt_usage else {
193        return false;
194    };
195    let Some(max_context) = max_context_tokens else {
196        return false;
197    };
198    let usable = max_context.saturating_sub(COMPACTION_BUFFER_TOKENS.min(max_context));
199    usage.context_budget_tokens >= usable
200}
201
202fn compaction_turn_id(parent_turn_id: &str) -> String {
203    format!("{parent_turn_id}:rolling-history-compaction")
204}
205
206async fn summarize_compaction_prefix(
207    session_id: &str,
208    state: &SessionSnapshot,
209    prefix_messages: Vec<Message>,
210    instructions: Option<&str>,
211    session_lifecycle: Arc<dyn lash_core::plugin::runtime_host::SessionLifecycleService>,
212    scoped_effect_controller: lash_core::ScopedEffectController<'_>,
213) -> Result<Option<String>, HistoryError> {
214    if prefix_messages.is_empty() {
215        return Ok(None);
216    }
217
218    let mut snapshot = lash_core::runtime::RuntimeSessionState::from_snapshot(state.clone());
219    snapshot.policy.max_turns = Some(1);
220    let mut messages = prefix_messages;
221    strip_all_image_attachments(&mut messages, COMPACTED_IMAGE_PLACEHOLDER);
222    snapshot.execution_state_snapshot = None;
223    snapshot.last_prompt_usage = None;
224    let previous_summary = extract_previous_summary(&messages);
225    snapshot.replace_active_read_state(&messages);
226
227    let compaction_session_id = format!("{session_id}-compaction");
228    let mut policy = snapshot.policy.clone();
229    policy.max_turns = Some(1);
230    let request = SessionCreateRequest::child(
231        session_id,
232        SessionStartPoint::Snapshot {
233            snapshot: Box::new(snapshot.to_snapshot()),
234        },
235        policy,
236        PluginOptions::default(),
237        "compaction",
238    )
239    .with_context_surface(SessionContextSurface {
240        include_base_tools: false,
241        tool_providers: Vec::new(),
242        prompt_contributions: Vec::new(),
243    })
244    .with_session_id(compaction_session_id);
245    let handle = session_lifecycle
246        .create_session(request)
247        .await
248        .map_err(HistoryError::from)?;
249
250    let base_prompt = match previous_summary {
251        Some(prev) => compaction_update_prompt(&prev),
252        None => COMPACTION_PROMPT.to_string(),
253    };
254    let prompt_text = with_instructions(&base_prompt, instructions);
255
256    let turn_id = compaction_turn_id(scoped_effect_controller.scope_id());
257    let compaction_effect_controller = lash_core::ScopedEffectController::borrowed(
258        scoped_effect_controller.controller(),
259        lash_core::EffectScope::turn(&handle.session_id, &turn_id),
260    )
261    .map_err(|err| HistoryError::Session(err.to_string()))?;
262    let request = lash_core::SessionTurnRequest::new(
263        &handle.session_id,
264        &turn_id,
265        TurnInput {
266            items: vec![InputItem::Text { text: prompt_text }],
267            image_blobs: HashMap::new(),
268            protocol_turn_options: None,
269            trace_turn_id: None,
270            protocol_extension: None,
271            turn_context: lash_core::TurnContext::default(),
272        },
273        compaction_effect_controller,
274    )
275    .map_err(|err| HistoryError::Session(err.to_string()))?;
276    let turn = session_lifecycle.start_turn(request).await;
277    let _ = session_lifecycle.close_session(&handle.session_id).await;
278    let turn = turn.map_err(HistoryError::from)?;
279    let summary = turn.assistant_output.safe_text.trim().to_string();
280    if summary.is_empty() {
281        return Ok(None);
282    }
283    Ok(Some(summary))
284}
285
286fn apply_compaction_summary(messages: &[Message], summary: &str, cut_point: usize) -> Vec<Message> {
287    let prefix_len = leading_system_prefix_len(messages);
288    if cut_point <= prefix_len || cut_point > messages.len() {
289        return messages.to_vec();
290    }
291
292    let mut out = Vec::new();
293    out.extend_from_slice(&messages[..prefix_len]);
294    out.push(Message {
295        id: "compaction-summary".to_string(),
296        role: MessageRole::Assistant,
297        parts: lash_core::shared_parts(vec![Part {
298            id: "compaction-summary.p0".to_string(),
299            kind: PartKind::Prose,
300            content: format!("{COMPACTION_SUMMARY_TITLE}\n{summary}"),
301            attachment: None,
302            tool_call_id: None,
303            tool_name: None,
304            tool_replay: None,
305            prune_state: lash_core::PruneState::Intact,
306            reasoning_meta: None,
307            response_meta: None,
308        }]),
309        origin: Some(MessageOrigin::Plugin {
310            plugin_id: ROLLING_HISTORY_PLUGIN_ID.to_string(),
311            transient: false,
312        }),
313    });
314    out.extend_from_slice(&messages[cut_point..]);
315    out
316}
317
318async fn compact_messages_core(
319    session_id: &str,
320    state: &SessionSnapshot,
321    messages: &[Message],
322    instructions: Option<&str>,
323    session_lifecycle: Arc<dyn lash_core::plugin::runtime_host::SessionLifecycleService>,
324    scoped_effect_controller: lash_core::ScopedEffectController<'_>,
325) -> Result<Option<Vec<Message>>, HistoryError> {
326    let prefix_len = leading_system_prefix_len(messages);
327    let cut_point = find_compaction_cut_point(messages, prefix_len);
328    if cut_point <= prefix_len {
329        return Ok(None);
330    }
331    let prefix_messages = messages[prefix_len..cut_point].to_vec();
332    let Some(summary) = summarize_compaction_prefix(
333        session_id,
334        state,
335        prefix_messages,
336        instructions,
337        session_lifecycle,
338        scoped_effect_controller,
339    )
340    .await?
341    else {
342        return Ok(None);
343    };
344    Ok(Some(apply_compaction_summary(
345        messages, &summary, cut_point,
346    )))
347}
348
349pub struct RollingHistoryPluginFactory {
350    config: RollingHistoryConfig,
351}
352
353impl RollingHistoryPluginFactory {
354    pub fn new(config: RollingHistoryConfig) -> Self {
355        Self { config }
356    }
357}
358
359impl Default for RollingHistoryPluginFactory {
360    fn default() -> Self {
361        Self::new(RollingHistoryConfig)
362    }
363}
364
365impl PluginFactory for RollingHistoryPluginFactory {
366    fn id(&self) -> &'static str {
367        ROLLING_HISTORY_PLUGIN_ID
368    }
369
370    fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
371        Ok(Arc::new(RollingHistoryPlugin {
372            config: self.config.clone(),
373        }))
374    }
375}
376
377struct RollingHistoryPlugin {
378    config: RollingHistoryConfig,
379}
380
381impl SessionPlugin for RollingHistoryPlugin {
382    fn id(&self) -> &'static str {
383        ROLLING_HISTORY_PLUGIN_ID
384    }
385
386    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
387        let config = self.config.clone();
388        reg.history()
389            .prepare_turn(100, Arc::new(RollingTurnTransform::new(config.clone())));
390        reg.history()
391            .rewrite(100, Arc::new(RollingHistoryRewriter::new(config)));
392        Ok(())
393    }
394}
395
396struct RollingTurnTransform;
397
398impl RollingTurnTransform {
399    fn new(_config: RollingHistoryConfig) -> Self {
400        Self
401    }
402}
403
404#[async_trait]
405impl TurnContextTransform for RollingTurnTransform {
406    fn id(&self) -> &'static str {
407        "rolling_history.prepare_turn"
408    }
409
410    async fn transform(
411        &self,
412        ctx: &TurnTransformContext<'_>,
413        mut input: PreparedContext,
414    ) -> Result<PreparedContext, HistoryError> {
415        let state = &ctx.state;
416        let prompt_usage = ctx.prompt_usage.as_ref();
417        let max_context_tokens = ctx.max_context_tokens;
418        let session_lifecycle = Arc::clone(&ctx.session_lifecycle);
419
420        let needs_pruning = pruning_needed(prompt_usage, max_context_tokens);
421        let needs_compaction = compaction_needed(prompt_usage, max_context_tokens);
422        if !needs_pruning && !needs_compaction {
423            return Ok(input);
424        }
425
426        let messages = input.messages.make_mut();
427
428        if needs_pruning {
429            prune_old_images(messages);
430        }
431
432        if !needs_compaction {
433            return Ok(input);
434        }
435
436        let messages = input.messages.as_slice();
437        let prefix_len = leading_system_prefix_len(messages);
438        let cut_point = find_compaction_cut_point(messages, prefix_len);
439        if cut_point <= prefix_len {
440            return Ok(input);
441        }
442
443        let prefix_messages = messages[prefix_len..cut_point].to_vec();
444        let Some(summary) = summarize_compaction_prefix(
445            &ctx.session_id,
446            &state.to_snapshot(),
447            prefix_messages,
448            None,
449            session_lifecycle,
450            ctx.scoped_effect_controller.clone(),
451        )
452        .await?
453        else {
454            return Ok(input);
455        };
456
457        input
458            .messages
459            .replace(apply_compaction_summary(messages, &summary, cut_point));
460        Ok(input)
461    }
462}
463
464struct RollingHistoryRewriter;
465
466impl RollingHistoryRewriter {
467    fn new(_config: RollingHistoryConfig) -> Self {
468        Self
469    }
470}
471
472#[async_trait]
473impl HistoryRewriter for RollingHistoryRewriter {
474    fn id(&self) -> &'static str {
475        "rolling_history.rewrite"
476    }
477
478    async fn rewrite(
479        &self,
480        ctx: &RewriteContext<'_>,
481        mut input: HistoryState,
482    ) -> Result<HistoryState, HistoryError> {
483        let session_id = ctx.session_id.clone();
484        let session_lifecycle = Arc::clone(&ctx.session_lifecycle);
485        let scoped_effect_controller = ctx.scoped_effect_controller.clone();
486
487        match &ctx.trigger {
488            RewriteTrigger::Manual { instructions } => {
489                if let Some(compacted) = compact_messages_core(
490                    &session_id,
491                    &ctx.state.to_snapshot(),
492                    &input.messages,
493                    instructions.as_deref(),
494                    session_lifecycle.clone(),
495                    scoped_effect_controller.clone(),
496                )
497                .await?
498                {
499                    input.metadata.produced_summary = true;
500                    input.metadata.pruned_message_count =
501                        input.messages.len().saturating_sub(compacted.len()) as u32;
502                    input.messages = compacted;
503                }
504                Ok(input)
505            }
506            RewriteTrigger::WindowShrink { .. } => {
507                if !compaction_needed(
508                    ctx.state.last_prompt_usage(),
509                    Some(ctx.state.policy().context_window_tokens()),
510                ) {
511                    return Ok(input);
512                }
513                if let Some(compacted) = compact_messages_core(
514                    &session_id,
515                    &ctx.state.to_snapshot(),
516                    &input.messages,
517                    None,
518                    session_lifecycle,
519                    scoped_effect_controller,
520                )
521                .await?
522                {
523                    input.metadata.produced_summary = true;
524                    input.messages = compacted;
525                }
526                Ok(input)
527            }
528            RewriteTrigger::Periodic => Ok(input),
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use lash_core::SessionPolicy;
537    use serde_json::json;
538
539    fn text_message(id: &str, role: MessageRole, content: &str) -> Message {
540        Message {
541            id: id.to_string(),
542            role,
543            parts: vec![Part {
544                id: format!("{id}.p0"),
545                kind: PartKind::Text,
546                content: content.to_string(),
547                attachment: None,
548                tool_call_id: None,
549                tool_name: None,
550                tool_replay: None,
551                prune_state: lash_core::PruneState::Intact,
552                reasoning_meta: None,
553                response_meta: None,
554            }]
555            .into(),
556            origin: None,
557        }
558    }
559
560    fn image_message(id: &str, role: MessageRole, bytes: &[u8]) -> Message {
561        Message {
562            id: id.to_string(),
563            role,
564            parts: vec![Part {
565                id: format!("{id}.p0"),
566                kind: PartKind::Image,
567                content: String::new(),
568                attachment: Some(lash_core::session_model::message::PartAttachment {
569                    reference: lash_core::AttachmentRef {
570                        id: lash_core::AttachmentId::new(format!("{id}-att")),
571                        media_type: lash_core::MediaType::Image(lash_core::ImageMediaType::Png),
572                        byte_len: bytes.len() as u64,
573                        width: None,
574                        height: None,
575                        label: None,
576                    },
577                }),
578                tool_call_id: None,
579                tool_name: None,
580                tool_replay: None,
581                prune_state: lash_core::PruneState::Intact,
582                reasoning_meta: None,
583                response_meta: None,
584            }]
585            .into(),
586            origin: None,
587        }
588    }
589
590    use lash_core::testing::{MockSessionManager, mock_assembled_turn as empty_turn};
591
592    fn mock_manager() -> MockSessionManager {
593        MockSessionManager::default()
594            .with_tool_catalog(vec![
595                json!({"name":"exec_command"}),
596                json!({"name":"read_file"}),
597            ])
598            .with_turn(empty_turn("root", "Compacted work summary"))
599    }
600
601    fn build_turn_ctx(
602        session_id: &str,
603        state: SessionSnapshot,
604        prompt_usage: Option<PromptUsage>,
605        max_context_tokens: Option<usize>,
606        manager: Arc<MockSessionManager>,
607    ) -> TurnTransformContext<'static> {
608        TurnTransformContext {
609            session_id: session_id.to_string(),
610            state: state.read_view(),
611            prompt_usage,
612            max_context_tokens,
613            sessions: manager.clone(),
614            session_lifecycle: manager.clone(),
615            session_graph: manager,
616            scoped_effect_controller: lash_core::ScopedEffectController::shared(
617                Arc::new(lash_core::InlineRuntimeEffectController),
618                lash_core::EffectScope::turn(session_id, "rolling-history-test-turn"),
619            )
620            .expect("test scoped effect controller"),
621            direct_completions: lash_core::DirectCompletionClient::from_fn(|_, _| {
622                Err(lash_core::PluginError::Session(
623                    "direct completions are unavailable in rolling history tests".to_string(),
624                ))
625            }),
626        }
627    }
628
629    #[tokio::test]
630    async fn rolling_turn_transform_strips_old_image_attachments() {
631        let messages = vec![
632            image_message("u0", MessageRole::User, &[1, 2, 3]),
633            text_message("u1", MessageRole::User, "recent"),
634            text_message("u2", MessageRole::User, "latest"),
635        ];
636
637        let state = SessionSnapshot::default();
638        let manager = Arc::new(mock_manager());
639        let transform = RollingTurnTransform::new(RollingHistoryConfig);
640        let ctx = build_turn_ctx(
641            "root",
642            state,
643            Some(PromptUsage {
644                prompt_context_tokens: 130_000,
645                input_tokens: 130_000,
646                cached_input_tokens: 0,
647                context_budget_tokens: 130_000,
648            }),
649            Some(200_000),
650            manager,
651        );
652        let prepared = PreparedContext {
653            messages: messages.into(),
654            ..Default::default()
655        };
656        let built = transform
657            .transform(&ctx, prepared)
658            .await
659            .expect("transform")
660            .messages;
661
662        let image_part = built[0].parts.first().expect("image part");
663        assert!(matches!(image_part.kind, PartKind::Image));
664        assert!(image_part.attachment.is_none());
665        assert_eq!(image_part.content, PRUNED_IMAGE_PLACEHOLDER);
666    }
667
668    #[tokio::test]
669    async fn rolling_turn_transform_replaces_prefix_with_summary() {
670        let manager = Arc::new(mock_manager());
671        let transform = RollingTurnTransform::new(RollingHistoryConfig);
672        let state = SessionSnapshot {
673            session_id: "root".to_string(),
674            policy: SessionPolicy::default(),
675            ..Default::default()
676        };
677        let ctx = build_turn_ctx(
678            "root",
679            state,
680            Some(PromptUsage {
681                prompt_context_tokens: 90_000,
682                input_tokens: 90_000,
683                cached_input_tokens: 0,
684                context_budget_tokens: 90_000,
685            }),
686            Some(100_000),
687            manager.clone(),
688        );
689        let prepared = PreparedContext {
690            messages: vec![
691                text_message("u1", MessageRole::User, "old work"),
692                text_message("a1", MessageRole::Assistant, "assistant old"),
693                text_message("u2", MessageRole::User, "latest request"),
694            ]
695            .into(),
696            ..Default::default()
697        };
698        let built = transform
699            .transform(&ctx, prepared)
700            .await
701            .expect("transform")
702            .messages;
703
704        assert!(built.iter().any(|message| {
705            message
706                .parts
707                .iter()
708                .any(|part| part.content.contains("Compacted work summary"))
709        }));
710        assert!(built.iter().any(|message| {
711            message
712                .parts
713                .iter()
714                .any(|part| part.content.contains("latest request"))
715        }));
716        assert!(!built.iter().any(|message| {
717            message
718                .parts
719                .iter()
720                .any(|part| part.content.contains("old work"))
721        }));
722
723        let created = manager.created_snapshot();
724        assert_eq!(created.len(), 1);
725        let turns = manager.turns.lock().expect("turns lock").clone();
726        assert_eq!(turns.len(), 1);
727        assert_eq!(
728            turns[0].1,
729            "rolling-history-test-turn:rolling-history-compaction"
730        );
731        assert_eq!(
732            turns[0].2.as_deref(),
733            Some("rolling-history-test-turn:rolling-history-compaction")
734        );
735        assert_eq!(
736            turns[0].3,
737            lash_core::EffectScope::turn(
738                "root-compaction",
739                "rolling-history-test-turn:rolling-history-compaction"
740            )
741        );
742    }
743}