1use 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;
31pub(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
133fn 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; }
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}