Skip to main content

lash_standard_plugins/
rolling_history.rs

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