Skip to main content

bamboo_engine/
llm_summarizer.rs

1//! LLM-backed conversation summarizer.
2//!
3//! `LlmSummarizer` is the infrastructure-coupled implementation of
4//! `bamboo_compression::Summarizer`: it calls the session model to produce a
5//! rich summary of compressed/removed messages, falling back to the pure
6//! `HeuristicSummarizer` on failure. It lives in the engine (not in
7//! bamboo-compression) so that the compression crate stays free of any
8//! LLM-provider dependency.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use futures::StreamExt;
14
15use bamboo_compression::{HeuristicSummarizer, Summarizer};
16use bamboo_domain::ReasoningEffort;
17use bamboo_domain::{
18    ContextBlock, ContextBlockPriority, ContextBlockStability, ContextBlockType, Message, Role,
19};
20use bamboo_llm::LLMChunk;
21use bamboo_llm::{LLMProvider, LLMRequestOptions};
22
23/// Mode controlling how the LLM summarizer handles existing summaries.
24#[derive(Debug, Clone, Default)]
25pub enum SummaryMode {
26    /// Generate a complete summary from scratch (default).
27    #[default]
28    FullRewrite,
29    /// Update an existing summary by incorporating new information incrementally.
30    IncrementalMerge,
31}
32
33/// LLM-based summarizer that calls the current session's model to generate
34/// a rich summary of compressed/removed messages.
35///
36/// Falls back to [`HeuristicSummarizer`] if the LLM call fails, UNLESS
37/// [`with_heuristic_fallback_on_error(false)`](Self::with_heuristic_fallback_on_error)
38/// is set — in which case a transient LLM *error* surfaces to the caller so a
39/// best-effort caller (mid-turn context compression) can skip and degrade
40/// gracefully instead of silently substituting a low-quality heuristic summary.
41pub struct LlmSummarizer {
42    llm: Arc<dyn LLMProvider>,
43    model: String,
44    /// Optional existing summary to build upon (incremental summarization).
45    existing_summary: Option<String>,
46    /// Structured runtime context blocks that should inform summarization.
47    context_blocks: Vec<ContextBlock>,
48    /// Optional user-provided instructions that override/extend the default summary focus.
49    custom_instructions: Option<String>,
50    /// Controls how the summarizer handles existing summaries.
51    summary_mode: SummaryMode,
52    /// When true (default), a transient LLM call/stream failure is recovered by
53    /// falling back to the [`HeuristicSummarizer`]. When false, that error is
54    /// returned to the caller instead — used by best-effort callers that would
55    /// rather SKIP compression than emit a degraded heuristic summary. The
56    /// empty-response fallback is unaffected either way. (issue #238)
57    heuristic_fallback_on_error: bool,
58}
59
60impl LlmSummarizer {
61    pub fn new(
62        llm: Arc<dyn LLMProvider>,
63        model: String,
64        existing_summary: Option<String>,
65        task_list_prompt: Option<String>,
66    ) -> Self {
67        let context_blocks = task_list_prompt
68            .as_deref()
69            .map(str::trim)
70            .filter(|value| !value.is_empty())
71            .map(|task_list| {
72                vec![ContextBlock::new(
73                    ContextBlockType::TaskSnapshot,
74                    ContextBlockPriority::High,
75                    ContextBlockStability::RoundDynamic,
76                    "Current Task List",
77                    task_list,
78                )]
79            })
80            .unwrap_or_default();
81
82        Self {
83            llm,
84            model,
85            existing_summary,
86            context_blocks,
87            custom_instructions: None,
88            summary_mode: SummaryMode::default(),
89            heuristic_fallback_on_error: true,
90        }
91    }
92
93    /// Control whether a transient LLM *error* recovers via the heuristic
94    /// summarizer (default `true`) or surfaces to the caller (`false`). Set
95    /// `false` for best-effort compression (mid-turn) that should skip rather
96    /// than substitute a heuristic summary. (issue #238)
97    pub fn with_heuristic_fallback_on_error(mut self, enabled: bool) -> Self {
98        self.heuristic_fallback_on_error = enabled;
99        self
100    }
101
102    pub fn with_context_blocks(mut self, context_blocks: Vec<ContextBlock>) -> Self {
103        self.context_blocks = context_blocks;
104        self
105    }
106
107    pub fn with_custom_instructions(mut self, instructions: Option<String>) -> Self {
108        self.custom_instructions = instructions;
109        self
110    }
111
112    pub fn with_summary_mode(mut self, mode: SummaryMode) -> Self {
113        self.summary_mode = mode;
114        self
115    }
116
117    /// Build the summarization prompt for the LLM.
118    fn build_summarization_messages(&self, messages: &[Message]) -> Vec<Message> {
119        let mut prompt_messages = Vec::new();
120
121        let system_prompt = match self.summary_mode {
122            SummaryMode::FullRewrite => {
123                r#"You are a conversation summarizer. Your task is to create a concise but reliable working-memory summary for a conversation that was removed due to context window limits.
124
125Guidelines:
126- First capture the in-flight work right before compression (what was being done, where, and with which tool/file)
127- Distinguish clearly between CURRENT ACTIVE work, COMPLETED work, and OBSOLETE or superseded work
128- Do not restate old tasks as active unless they are still unresolved
129- The provided current task list is the source of truth for active work
130- Preserve key decisions, constraints, file paths, code changes, tool findings, blockers, and important outcomes
131- Preserve error messages, test results (pass/fail counts), and function/variable names that are relevant to active work
132- If earlier plans conflict with newer messages or the current task list, mark them as obsolete or completed
133- Explicitly evaluate each clear user requirement (e.g. requirement 1, requirement 2) with a status and evidence
134- Keep the next step specific and aligned with the active work only
135- Use structured sections
136- Write in the same language as the original conversation"#
137            }
138            SummaryMode::IncrementalMerge => {
139                r#"You are updating an existing conversation summary with new information from recent messages.
140
141Guidelines:
142- Incorporate new information into the existing summary structure
143- Mark previously active work as completed if the new messages confirm completion
144- Remove or condense information that is no longer relevant
145- Preserve all key decisions, file paths, and constraints that remain active
146- If new messages conflict with the existing summary, the new messages take precedence
147- Keep the summary focused on what is currently active and relevant
148- The provided current task list is the source of truth for active work
149- Maintain the same structured sections as the existing summary
150- Write in the same language as the original conversation
151- Be concise: avoid repeating information already well-captured in the existing summary"#
152            }
153        };
154
155        prompt_messages.push(Message::system(system_prompt));
156
157        let mut user_content = String::new();
158
159        if let Some(ref existing) = self.existing_summary {
160            user_content.push_str("## Previous Summary\n\n");
161            user_content.push_str(existing);
162            user_content.push_str("\n\n---\n\n");
163        }
164
165        if !self.context_blocks.is_empty() {
166            user_content.push_str("## Compression Context Blocks\n\n");
167            for block in &self.context_blocks {
168                user_content.push_str(&format!(
169                    "### {}\n- type: {}\n- priority: {}\n- stability: {}\n\n{}\n\n",
170                    block.title.trim(),
171                    block.block_type.as_str(),
172                    block.priority.as_str(),
173                    block.stability.as_str(),
174                    block.content.trim(),
175                ));
176            }
177            user_content.push_str("---\n\n");
178        }
179
180        if let Some(ref instructions) = self.custom_instructions {
181            if !instructions.trim().is_empty() {
182                user_content.push_str("## Custom Compression Instructions\n\n");
183                user_content.push_str(instructions.trim());
184                user_content.push_str("\n\n---\n\n");
185            }
186        }
187
188        user_content.push_str(
189            "## Required Output Sections\n1. Pre-compression in-flight work (what was being done immediately before compression)\n2. Current active objective\n3. Requirement checklist (Requirement | Status: completed/in_progress/pending/blocked/obsolete | Evidence)\n4. Active tasks\n5. Completed tasks\n6. Obsolete or superseded tasks\n7. Important context and constraints\n8. Files, code, and tool findings\n9. Open issues and next step\n\n",
190        );
191
192        user_content.push_str("## Messages to Summarize\n\n");
193
194        for message in messages {
195            let role_label = match message.role {
196                Role::User => "User",
197                Role::Assistant => "Assistant",
198                Role::Tool => "Tool Result",
199                Role::System => continue,
200            };
201
202            if let Some(ref tool_calls) = message.tool_calls {
203                if !tool_calls.is_empty() {
204                    let tool_names: Vec<&str> = tool_calls
205                        .iter()
206                        .map(|tc| tc.function.name.as_str())
207                        .collect();
208                    user_content.push_str(&format!(
209                        "**{}** [called tools: {}]:\n",
210                        role_label,
211                        tool_names.join(", ")
212                    ));
213                } else {
214                    user_content.push_str(&format!("**{}**:\n", role_label));
215                }
216            } else {
217                user_content.push_str(&format!("**{}**:\n", role_label));
218            }
219
220            if let Some(ref tool_call_id) = message.tool_call_id {
221                user_content.push_str(&format!("(tool_call_id: {})\n", tool_call_id));
222            }
223
224            let content = &message.content;
225            const MAX_CONTENT_CHARS: usize = 2000;
226            if content.chars().count() > MAX_CONTENT_CHARS {
227                let truncated: String = content.chars().take(MAX_CONTENT_CHARS).collect();
228                user_content.push_str(&truncated);
229                user_content.push_str("... [truncated]\n\n");
230            } else {
231                user_content.push_str(content);
232                user_content.push_str("\n\n");
233            }
234        }
235
236        user_content.push_str(
237            "\n---\n\nReturn only the summary text. Be explicit about what is active now versus what is already completed or no longer relevant.",
238        );
239
240        prompt_messages.push(Message::user(user_content));
241
242        prompt_messages
243    }
244
245    /// Consume an LLM stream and collect the full text response.
246    async fn collect_stream_response(
247        &self,
248        messages: &[Message],
249    ) -> Result<String, bamboo_compression::types::BudgetError> {
250        // Summarization is a lightweight auxiliary request; cap reasoning effort at `high`
251        // to stay compatible with fast models (e.g. gpt-5-mini).
252        let options = LLMRequestOptions {
253            session_id: None,
254            reasoning_effort: Some(ReasoningEffort::High),
255            parallel_tool_calls: None,
256            required_tool: None,
257            responses: None,
258            request_purpose: Some("compression".to_string()),
259            cache: None,
260        };
261        let stream = self
262            .llm
263            .chat_stream_with_options(messages, &[], Some(8192), &self.model, Some(&options))
264            .await
265            .map_err(|e| {
266                bamboo_compression::types::BudgetError::TokenCountError(format!(
267                    "LLM summarization call failed: {}",
268                    e
269                ))
270            })?;
271
272        let mut content = String::new();
273        let mut stream = stream;
274
275        while let Some(chunk_result) = stream.next().await {
276            match chunk_result {
277                Ok(LLMChunk::Token(text)) => content.push_str(&text),
278                Ok(LLMChunk::Done) => break,
279                Ok(_) => {} // Ignore reasoning tokens, tool calls, etc.
280                Err(e) => {
281                    tracing::warn!("LLM summarization stream error: {}", e);
282                    if !content.is_empty() {
283                        break;
284                    }
285                    return Err(bamboo_compression::types::BudgetError::TokenCountError(
286                        format!("LLM summarization stream failed: {}", e),
287                    ));
288                }
289            }
290        }
291
292        Ok(content)
293    }
294}
295
296impl std::fmt::Debug for LlmSummarizer {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        f.debug_struct("LlmSummarizer")
299            .field("model", &self.model)
300            .field("has_existing_summary", &self.existing_summary.is_some())
301            .field("context_block_count", &self.context_blocks.len())
302            .finish()
303    }
304}
305
306#[async_trait]
307impl Summarizer for LlmSummarizer {
308    async fn summarize(
309        &self,
310        messages: &[Message],
311    ) -> Result<String, bamboo_compression::types::BudgetError> {
312        if messages.is_empty() {
313            return Ok("No conversation history to summarize.".to_string());
314        }
315
316        let prompt_messages = self.build_summarization_messages(messages);
317
318        tracing::info!(
319            "LlmSummarizer: summarizing {} messages using model '{}' (existing_summary={})",
320            messages.len(),
321            self.model,
322            self.existing_summary.is_some()
323        );
324
325        match self.collect_stream_response(&prompt_messages).await {
326            Ok(summary) if !summary.trim().is_empty() => {
327                tracing::info!("LlmSummarizer: generated summary ({} chars)", summary.len());
328                Ok(summary)
329            }
330            Ok(_) => {
331                tracing::warn!(
332                    "LlmSummarizer: LLM returned empty summary, falling back to heuristic"
333                );
334                HeuristicSummarizer::new().summarize(messages).await
335            }
336            Err(e) if self.heuristic_fallback_on_error => {
337                tracing::warn!(
338                    "LlmSummarizer: LLM call failed ({}), falling back to heuristic",
339                    e
340                );
341                HeuristicSummarizer::new().summarize(messages).await
342            }
343            // Best-effort caller opted out of the heuristic fallback: surface the
344            // transient error so the caller can skip compression entirely rather
345            // than substitute a low-quality heuristic summary. (issue #238)
346            Err(e) => {
347                tracing::warn!(
348                    "LlmSummarizer: LLM call failed ({}); surfacing error (heuristic fallback disabled)",
349                    e
350                );
351                Err(e)
352            }
353        }
354    }
355
356    fn estimate_summary_tokens(&self, message_count: usize) -> u32 {
357        // LLM summaries tend to be more detailed; estimate higher than heuristic
358        (message_count * 80).min(2000) as u32
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use bamboo_domain::ReasoningEffort;
366    use bamboo_llm::{LLMChunk, LLMError, LLMRequestOptions, LLMStream};
367    use futures::stream;
368    use std::sync::Mutex;
369
370    struct DummyProvider;
371
372    #[async_trait]
373    impl LLMProvider for DummyProvider {
374        async fn chat_stream(
375            &self,
376            _messages: &[Message],
377            _tools: &[bamboo_domain::ToolSchema],
378            _max_output_tokens: Option<u32>,
379            _model: &str,
380        ) -> Result<LLMStream, LLMError> {
381            Ok(Box::pin(stream::iter(vec![
382                Ok::<LLMChunk, LLMError>(LLMChunk::Token("dummy summary".to_string())),
383                Ok::<LLMChunk, LLMError>(LLMChunk::Done),
384            ])))
385        }
386    }
387    #[test]
388    fn llm_summarizer_prompt_includes_context_blocks_and_state_sections() {
389        let summarizer = LlmSummarizer::new(
390            Arc::new(DummyProvider),
391            "gpt-4o-mini".to_string(),
392            Some("Earlier summary".to_string()),
393            Some(
394                "## Current Task List\n[/] task_1: Fix compression bounce\n[x] task_0: Analyze bug"
395                    .to_string(),
396            ),
397        )
398        .with_context_blocks(vec![
399            ContextBlock::new(
400                ContextBlockType::TaskSnapshot,
401                ContextBlockPriority::High,
402                ContextBlockStability::RoundDynamic,
403                "Current Task List",
404                "[/] task_1: Fix compression bounce",
405            ),
406            ContextBlock::new(
407                ContextBlockType::ExternalMemory,
408                ContextBlockPriority::Medium,
409                ContextBlockStability::RoundDynamic,
410                "External Memory (Persistent)",
411                "Session note body",
412            ),
413        ]);
414        let messages = vec![
415            Message::user("继续做压缩修复"),
416            Message::assistant("我先检查 trigger 与 target", None),
417        ];
418
419        let prompt_messages = summarizer.build_summarization_messages(&messages);
420        assert_eq!(prompt_messages.len(), 2);
421        assert_eq!(prompt_messages[0].role, Role::System);
422        assert!(prompt_messages[1]
423            .content
424            .contains("## Compression Context Blocks"));
425        assert!(prompt_messages[1].content.contains("Current Task List"));
426        assert!(prompt_messages[1]
427            .content
428            .contains("External Memory (Persistent)"));
429        assert!(prompt_messages[1]
430            .content
431            .contains("Current active objective"));
432        assert!(prompt_messages[1].content.contains("Requirement checklist"));
433        assert!(prompt_messages[1].content.contains("Active tasks"));
434        assert!(prompt_messages[1].content.contains("Completed tasks"));
435        assert!(prompt_messages[1]
436            .content
437            .contains("Obsolete or superseded tasks"));
438        assert!(prompt_messages[1].content.contains("Earlier summary"));
439    }
440
441    #[derive(Default)]
442    struct ReasoningCaptureProvider {
443        captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
444    }
445
446    #[async_trait]
447    impl LLMProvider for ReasoningCaptureProvider {
448        async fn chat_stream(
449            &self,
450            _messages: &[Message],
451            _tools: &[bamboo_domain::ToolSchema],
452            _max_output_tokens: Option<u32>,
453            _model: &str,
454        ) -> Result<LLMStream, LLMError> {
455            Ok(Box::pin(stream::iter(vec![
456                Ok::<LLMChunk, LLMError>(LLMChunk::Token("captured summary".to_string())),
457                Ok::<LLMChunk, LLMError>(LLMChunk::Done),
458            ])))
459        }
460
461        async fn chat_stream_with_options(
462            &self,
463            messages: &[Message],
464            tools: &[bamboo_domain::ToolSchema],
465            max_output_tokens: Option<u32>,
466            model: &str,
467            options: Option<&LLMRequestOptions>,
468        ) -> Result<LLMStream, LLMError> {
469            self.captured_reasoning
470                .lock()
471                .expect("captured reasoning lock should not be poisoned")
472                .push(options.and_then(|o| o.reasoning_effort));
473            self.chat_stream(messages, tools, max_output_tokens, model)
474                .await
475        }
476    }
477
478    #[tokio::test]
479    async fn llm_summarizer_requests_high_reasoning_effort_for_summary_calls() {
480        let provider = Arc::new(ReasoningCaptureProvider::default());
481        let summarizer = LlmSummarizer::new(
482            provider.clone(),
483            "gpt-5-mini".to_string(),
484            None,
485            Some("task list".to_string()),
486        );
487        let messages = vec![
488            Message::user("请总结最近三轮"),
489            Message::assistant("已完成第一步并准备第二步", None),
490        ];
491
492        let summary = summarizer
493            .summarize(&messages)
494            .await
495            .expect("summary generation should succeed");
496        assert_eq!(summary, "captured summary");
497
498        let captured = provider
499            .captured_reasoning
500            .lock()
501            .expect("captured reasoning lock should not be poisoned");
502        assert_eq!(captured.as_slice(), [Some(ReasoningEffort::High)]);
503    }
504
505    /// Provider that captures both `reasoning_effort` and `max_output_tokens`.
506    #[derive(Default)]
507    struct RequestOptionsCaptureProvider {
508        captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
509        captured_max_tokens: Mutex<Vec<Option<u32>>>,
510    }
511
512    #[async_trait]
513    impl LLMProvider for RequestOptionsCaptureProvider {
514        async fn chat_stream(
515            &self,
516            _messages: &[Message],
517            _tools: &[bamboo_domain::ToolSchema],
518            _max_output_tokens: Option<u32>,
519            _model: &str,
520        ) -> Result<LLMStream, LLMError> {
521            Ok(Box::pin(stream::iter(vec![
522                Ok::<LLMChunk, LLMError>(LLMChunk::Token("captured summary".to_string())),
523                Ok::<LLMChunk, LLMError>(LLMChunk::Done),
524            ])))
525        }
526
527        async fn chat_stream_with_options(
528            &self,
529            messages: &[Message],
530            tools: &[bamboo_domain::ToolSchema],
531            max_output_tokens: Option<u32>,
532            model: &str,
533            options: Option<&LLMRequestOptions>,
534        ) -> Result<LLMStream, LLMError> {
535            self.captured_reasoning
536                .lock()
537                .expect("lock should not be poisoned")
538                .push(options.and_then(|o| o.reasoning_effort));
539            self.captured_max_tokens
540                .lock()
541                .expect("lock should not be poisoned")
542                .push(max_output_tokens);
543            self.chat_stream(messages, tools, max_output_tokens, model)
544                .await
545        }
546    }
547
548    #[tokio::test]
549    async fn llm_summarizer_sufficient_max_tokens_for_high_reasoning() {
550        let provider = Arc::new(RequestOptionsCaptureProvider::default());
551        let summarizer = LlmSummarizer::new(
552            provider.clone(),
553            "gpt-5-mini".to_string(),
554            None,
555            Some("task list".to_string()),
556        );
557        let messages = vec![
558            Message::user("请总结最近三轮"),
559            Message::assistant("已完成第一步并准备第二步", None),
560        ];
561
562        let summary = summarizer
563            .summarize(&messages)
564            .await
565            .expect("summary generation should succeed");
566        assert_eq!(summary, "captured summary");
567
568        let captured_reasoning = provider
569            .captured_reasoning
570            .lock()
571            .expect("lock should not be poisoned");
572        let captured_max_tokens = provider
573            .captured_max_tokens
574            .lock()
575            .expect("lock should not be poisoned");
576        assert_eq!(captured_reasoning.as_slice(), [Some(ReasoningEffort::High)]);
577        let max_tokens = captured_max_tokens[0].expect("max_output_tokens should be set");
578        // ReasoningEffort::High targets 4096 thinking budget; max_tokens must leave room for output.
579        assert!(
580            max_tokens > 4096,
581            "max_output_tokens ({}) must exceed thinking budget (4096) to avoid truncation",
582            max_tokens
583        );
584    }
585
586    #[test]
587    fn full_rewrite_mode_uses_default_system_prompt() {
588        let summarizer =
589            LlmSummarizer::new(Arc::new(DummyProvider), "model".to_string(), None, None)
590                .with_summary_mode(SummaryMode::FullRewrite);
591        let messages = vec![Message::user("hello"), Message::assistant("hi", None)];
592        let prompts = summarizer.build_summarization_messages(&messages);
593        let system = &prompts[0].content;
594        assert!(
595            system.contains("conversation summarizer"),
596            "FullRewrite prompt should contain 'conversation summarizer'"
597        );
598        assert!(
599            !system.contains("updating an existing"),
600            "FullRewrite prompt should not contain incremental language"
601        );
602    }
603
604    #[test]
605    fn incremental_merge_mode_uses_update_system_prompt() {
606        let summarizer = LlmSummarizer::new(
607            Arc::new(DummyProvider),
608            "model".to_string(),
609            Some("Previous summary content".to_string()),
610            None,
611        )
612        .with_summary_mode(SummaryMode::IncrementalMerge);
613        let messages = vec![Message::user("hello"), Message::assistant("hi", None)];
614        let prompts = summarizer.build_summarization_messages(&messages);
615        let system = &prompts[0].content;
616        assert!(
617            system.contains("updating an existing conversation summary"),
618            "IncrementalMerge prompt should contain 'updating an existing conversation summary'"
619        );
620        assert!(
621            system.contains("Incorporate new information"),
622            "IncrementalMerge prompt should mention incorporating new information"
623        );
624    }
625
626    #[test]
627    fn default_summary_mode_is_full_rewrite() {
628        assert!(matches!(SummaryMode::default(), SummaryMode::FullRewrite));
629    }
630
631    #[test]
632    fn incremental_merge_includes_existing_summary_in_user_content() {
633        let summarizer = LlmSummarizer::new(
634            Arc::new(DummyProvider),
635            "model".to_string(),
636            Some("Previous summary content".to_string()),
637            None,
638        )
639        .with_summary_mode(SummaryMode::IncrementalMerge);
640        let messages = vec![
641            Message::user("new work"),
642            Message::assistant("doing it", None),
643        ];
644        let prompts = summarizer.build_summarization_messages(&messages);
645        let user_content = &prompts[1].content;
646        assert!(
647            user_content.contains("Previous Summary"),
648            "IncrementalMerge user prompt should include the existing summary"
649        );
650        assert!(
651            user_content.contains("Previous summary content"),
652            "IncrementalMerge user prompt should include the actual summary text"
653        );
654    }
655
656    /// Provider whose summarization stream call fails transiently (500/429/timeout).
657    struct FailingProvider;
658
659    #[async_trait]
660    impl LLMProvider for FailingProvider {
661        async fn chat_stream(
662            &self,
663            _messages: &[Message],
664            _tools: &[bamboo_domain::ToolSchema],
665            _max_output_tokens: Option<u32>,
666            _model: &str,
667        ) -> Result<LLMStream, LLMError> {
668            Err(LLMError::Api("http 500 transient".to_string()))
669        }
670    }
671
672    fn summary_messages() -> Vec<Message> {
673        vec![
674            Message::user("do the work"),
675            Message::assistant("working on it", None),
676            Message::user("keep going"),
677        ]
678    }
679
680    #[tokio::test]
681    async fn summarize_falls_back_to_heuristic_on_llm_error_by_default() {
682        // Default: a transient LLM failure is recovered by the heuristic
683        // summarizer, so summarize() succeeds. This is what makes pre-turn /
684        // overflow compression resilient (and, historically, masked #238).
685        let summarizer =
686            LlmSummarizer::new(Arc::new(FailingProvider), "model".to_string(), None, None);
687        let out = summarizer.summarize(&summary_messages()).await;
688        assert!(
689            out.is_ok(),
690            "default heuristic fallback should recover from a transient LLM error, got {out:?}"
691        );
692    }
693
694    #[tokio::test]
695    async fn summarize_surfaces_llm_error_when_heuristic_fallback_disabled() {
696        // Best-effort caller (mid-turn compression) opts out of the heuristic
697        // fallback: the transient error must SURFACE so the caller can skip
698        // compression rather than substitute a low-quality heuristic summary.
699        // (issue #238)
700        let summarizer =
701            LlmSummarizer::new(Arc::new(FailingProvider), "model".to_string(), None, None)
702                .with_heuristic_fallback_on_error(false);
703        let out = summarizer.summarize(&summary_messages()).await;
704        assert!(
705            out.is_err(),
706            "with the heuristic fallback disabled, a transient LLM error must surface"
707        );
708    }
709}