Skip to main content

pi/core/compaction/
branch.rs

1//! Branch summarization for tree navigation.
2//!
3//! Ports
4//! `.references/pi/packages/coding-agent/src/core/compaction/branch-summarization.ts`.
5
6use std::collections::BTreeMap;
7
8use futures::StreamExt;
9use pi_agent::AgentMessage;
10use pi_ai::{
11    AssistantContent, AssistantMessage, AssistantMessageEvent, Context, Message, Model, StopReason,
12    StreamOptions, TextContent, UserContent, UserMessage, UserMessageContent,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use tokio_util::sync::CancellationToken;
17
18use crate::core::messages::{
19    convert_to_llm, create_branch_summary_message, create_compaction_summary_message,
20    create_custom_message,
21};
22use crate::core::sessions::{SessionEntry, SessionManager};
23
24use super::{
25    CompactionError, FileOperations, SUMMARIZATION_SYSTEM_PROMPT, SummarizeStreamFn,
26    compute_file_lists, create_file_ops, estimate_tokens, extract_file_ops_from_message,
27    format_file_operations, serialize_conversation,
28};
29
30/// Default max tokens for branch summary generation.
31pub const DEFAULT_BRANCH_MAX_TOKENS: u64 = 2048;
32
33/// Default context window when the model reports 0 / missing.
34pub const DEFAULT_BRANCH_CONTEXT_WINDOW: u64 = 128_000;
35
36/// Default reserve tokens for branch summarization.
37pub const DEFAULT_BRANCH_RESERVE_TOKENS: u64 = 16_384;
38
39/// Preamble prepended to every generated branch summary.
40pub const BRANCH_SUMMARY_PREAMBLE: &str = "The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n";
41
42/// Default branch-summary instructions (exact TS text).
43pub const BRANCH_SUMMARY_PROMPT: &str = "Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
44
45/// Result of [`generate_branch_summary`].
46#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct BranchSummaryResult {
49    /// Generated summary text (with preamble + file ops).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub summary: Option<String>,
52    /// Read-only files tracked on the abandoned path.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub read_files: Option<Vec<String>>,
55    /// Modified files tracked on the abandoned path.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub modified_files: Option<Vec<String>>,
58    /// True when cancelled / aborted.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub aborted: Option<bool>,
61    /// Error message when summarization failed.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub error: Option<String>,
64}
65
66/// Details stored on a branch-summary entry for cumulative file tracking.
67#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct BranchSummaryDetails {
70    /// Paths only read.
71    pub read_files: Vec<String>,
72    /// Paths edited or written.
73    pub modified_files: Vec<String>,
74}
75
76/// Prepared abandoned-path messages under a token budget.
77#[derive(Clone, Debug)]
78pub struct BranchPreparation {
79    /// Messages extracted for summarization, chronological order.
80    pub messages: Vec<AgentMessage>,
81    /// File operations extracted from tool calls and nested summaries.
82    pub file_ops: FileOperations,
83    /// Total estimated tokens in `messages`.
84    pub total_tokens: u64,
85}
86
87/// Result of collecting abandoned-path entries.
88#[derive(Clone, Debug)]
89pub struct CollectEntriesResult {
90    /// Entries to summarize, chronological order.
91    pub entries: Vec<SessionEntry>,
92    /// Common ancestor between old and new positions, if any.
93    pub common_ancestor_id: Option<String>,
94}
95
96/// Options for [`generate_branch_summary`].
97pub struct GenerateBranchSummaryOptions {
98    /// Model to use.
99    pub model: Model,
100    /// Explicit API key.
101    pub api_key: Option<String>,
102    /// Optional request headers.
103    pub headers: Option<BTreeMap<String, Option<String>>>,
104    /// Provider-scoped environment overrides.
105    pub env: Option<BTreeMap<String, String>>,
106    /// Cancellation token.
107    pub signal: CancellationToken,
108    /// Optional custom instructions.
109    pub custom_instructions: Option<String>,
110    /// When true, `custom_instructions` **replaces** the default prompt.
111    pub replace_instructions: bool,
112    /// Tokens reserved for prompt + response (default 16384).
113    pub reserve_tokens: Option<u64>,
114    /// Injected summarizer stream.
115    pub stream_fn: SummarizeStreamFn,
116}
117
118/// Collect entries that should be summarized when navigating from one position
119/// to another (abandoned path from `old_leaf_id` back to the common ancestor
120/// with `target_id`).
121#[must_use]
122pub fn collect_entries_for_branch_summary(
123    session: &SessionManager,
124    old_leaf_id: Option<&str>,
125    target_id: &str,
126) -> CollectEntriesResult {
127    let Some(old_leaf_id) = old_leaf_id.filter(|s| !s.is_empty()) else {
128        return CollectEntriesResult {
129            entries: Vec::new(),
130            common_ancestor_id: None,
131        };
132    };
133
134    let old_path: std::collections::HashSet<String> = session
135        .get_branch(Some(old_leaf_id))
136        .into_iter()
137        .filter_map(|e| e.id().map(str::to_owned))
138        .collect();
139    let target_path = session.get_branch(Some(target_id));
140
141    let mut common_ancestor_id = None;
142    for entry in target_path.iter().rev() {
143        if let Some(id) = entry.id()
144            && old_path.contains(id)
145        {
146            common_ancestor_id = Some(id.to_owned());
147            break;
148        }
149    }
150
151    let mut entries = Vec::new();
152    let mut current = Some(old_leaf_id.to_owned());
153    while let Some(cur) = current {
154        if common_ancestor_id.as_deref() == Some(cur.as_str()) {
155            break;
156        }
157        let Some(entry) = session.get_entry(&cur) else {
158            break;
159        };
160        entries.push(entry.clone());
161        current = entry.parent_id().map(str::to_owned);
162    }
163    entries.reverse();
164
165    CollectEntriesResult {
166        entries,
167        common_ancestor_id,
168    }
169}
170
171fn get_message_from_entry(entry: &SessionEntry) -> Option<AgentMessage> {
172    match entry {
173        SessionEntry::Message(m) => {
174            if m.message.role() == "toolResult" {
175                return None;
176            }
177            Some(m.message.clone())
178        }
179        SessionEntry::CustomMessage(c) => {
180            let custom = create_custom_message(
181                &c.custom_type,
182                c.content.clone(),
183                c.display,
184                c.details.clone(),
185                &c.timestamp,
186            )
187            .ok()?;
188            product_to_agent(&custom)
189        }
190        SessionEntry::BranchSummary(b) => {
191            let msg = create_branch_summary_message(&b.summary, &b.from_id, &b.timestamp).ok()?;
192            product_to_agent(&msg)
193        }
194        SessionEntry::Compaction(c) => {
195            let msg = create_compaction_summary_message(&c.summary, c.tokens_before, &c.timestamp)
196                .ok()?;
197            product_to_agent(&msg)
198        }
199        _ => None,
200    }
201}
202
203fn product_to_agent<T: Serialize>(msg: &T) -> Option<AgentMessage> {
204    let value = serde_json::to_value(msg).ok()?;
205    let Value::Object(mut map) = value else {
206        return None;
207    };
208    let Some(Value::String(role)) = map.remove("role") else {
209        return None;
210    };
211    Some(AgentMessage::Custom(pi_agent::CustomAgentMessage::new(
212        role, map,
213    )))
214}
215
216/// Prepare entries for summarization with a newest→oldest token budget.
217///
218/// First pass collects file ops from **all** entries (including nested
219/// `branch_summary` details when `fromHook != true`). Second pass walks
220/// newest→oldest adding messages until the budget; summary entries may still
221/// be rescued when `total_tokens < budget * 0.9`.
222#[must_use]
223pub fn prepare_branch_entries(entries: &[SessionEntry], token_budget: u64) -> BranchPreparation {
224    let mut messages = Vec::new();
225    let mut file_ops = create_file_ops();
226    let mut total_tokens = 0u64;
227
228    // First pass: file ops from all entries (incl. nested branch summaries).
229    for entry in entries {
230        if let SessionEntry::BranchSummary(b) = entry {
231            let from_hook = b.from_hook.unwrap_or(false);
232            if !from_hook && let Some(details) = b.details.as_ref() {
233                if let Some(arr) = details.get("readFiles").and_then(Value::as_array) {
234                    for f in arr {
235                        if let Some(path) = f.as_str() {
236                            file_ops.read.insert(path.to_owned());
237                        }
238                    }
239                }
240                if let Some(arr) = details.get("modifiedFiles").and_then(Value::as_array) {
241                    for f in arr {
242                        if let Some(path) = f.as_str() {
243                            file_ops.edited.insert(path.to_owned());
244                        }
245                    }
246                }
247            }
248        }
249    }
250
251    // Second pass: newest → oldest under budget.
252    for entry in entries.iter().rev() {
253        let Some(message) = get_message_from_entry(entry) else {
254            continue;
255        };
256        extract_file_ops_from_message(&message, &mut file_ops);
257        let tokens = estimate_tokens(&message);
258
259        if token_budget > 0 && total_tokens.saturating_add(tokens) > token_budget {
260            // Rescue summary entries when still under 90% of budget.
261            if matches!(entry.discriminant(), "compaction" | "branch_summary") {
262                let rescue_limit = (token_budget / 10) * 9 + (token_budget % 10) * 9 / 10;
263                if total_tokens < rescue_limit {
264                    messages.insert(0, message);
265                    total_tokens = total_tokens.saturating_add(tokens);
266                }
267            }
268            break;
269        }
270
271        messages.insert(0, message);
272        total_tokens = total_tokens.saturating_add(tokens);
273    }
274
275    BranchPreparation {
276        messages,
277        file_ops,
278        total_tokens,
279    }
280}
281
282fn ensure_not_cancelled(signal: &CancellationToken) -> Result<(), CompactionError> {
283    if signal.is_cancelled() {
284        Err(CompactionError::Cancelled)
285    } else {
286        Ok(())
287    }
288}
289
290async fn complete_summarization(
291    model: &Model,
292    context: Context,
293    options: StreamOptions,
294    stream_fn: &SummarizeStreamFn,
295) -> Result<AssistantMessage, CompactionError> {
296    if options
297        .signal
298        .as_ref()
299        .is_some_and(CancellationToken::is_cancelled)
300    {
301        return Err(CompactionError::Cancelled);
302    }
303
304    let mut stream = stream_fn(model.clone(), context, options).await;
305    let mut last = None;
306    while let Some(item) = stream.next().await {
307        match item {
308            Ok(AssistantMessageEvent::Done { message, .. }) => return Ok(message),
309            Ok(AssistantMessageEvent::Error { error, .. }) => return Ok(error),
310            Ok(other) => {
311                if let Some(partial) = event_partial(&other) {
312                    last = Some(partial.clone());
313                }
314            }
315            Err(err) => return Err(CompactionError::Provider(err)),
316        }
317    }
318    last.ok_or_else(|| CompactionError::SummarizationFailed("Unknown error".to_owned()))
319}
320
321fn event_partial(event: &AssistantMessageEvent) -> Option<&AssistantMessage> {
322    match event {
323        AssistantMessageEvent::Start { partial }
324        | AssistantMessageEvent::TextStart { partial, .. }
325        | AssistantMessageEvent::TextDelta { partial, .. }
326        | AssistantMessageEvent::TextEnd { partial, .. }
327        | AssistantMessageEvent::ThinkingStart { partial, .. }
328        | AssistantMessageEvent::ThinkingDelta { partial, .. }
329        | AssistantMessageEvent::ThinkingEnd { partial, .. }
330        | AssistantMessageEvent::ToolCallStart { partial, .. }
331        | AssistantMessageEvent::ToolCallDelta { partial, .. }
332        | AssistantMessageEvent::ToolCallEnd { partial, .. } => Some(partial),
333        AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. } => None,
334    }
335}
336
337fn assistant_text(message: &AssistantMessage) -> String {
338    message
339        .content
340        .iter()
341        .filter_map(|block| match block {
342            AssistantContent::Text(text) => Some(text.text.to_string()),
343            _ => None,
344        })
345        .collect::<Vec<_>>()
346        .join("\n")
347}
348
349fn now_millis() -> i64 {
350    use std::time::{SystemTime, UNIX_EPOCH};
351    SystemTime::now()
352        .duration_since(UNIX_EPOCH)
353        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
354}
355
356/// Generate a summary of abandoned branch entries.
357///
358/// # Errors
359///
360/// Surfaces cancellation and provider failures; non-abort summarizer errors are
361/// returned as [`BranchSummaryResult::error`] (matching TS soft-error shape).
362pub async fn generate_branch_summary(
363    entries: &[SessionEntry],
364    options: GenerateBranchSummaryOptions,
365) -> Result<BranchSummaryResult, CompactionError> {
366    ensure_not_cancelled(&options.signal)?;
367
368    let reserve_tokens = options
369        .reserve_tokens
370        .unwrap_or(DEFAULT_BRANCH_RESERVE_TOKENS);
371    let context_window = if options.model.context_window == 0 {
372        DEFAULT_BRANCH_CONTEXT_WINDOW
373    } else {
374        options.model.context_window
375    };
376    let token_budget = context_window.saturating_sub(reserve_tokens);
377
378    let prepared = prepare_branch_entries(entries, token_budget);
379    if prepared.messages.is_empty() {
380        return Ok(BranchSummaryResult {
381            summary: Some("No content to summarize".to_owned()),
382            ..BranchSummaryResult::default()
383        });
384    }
385
386    ensure_not_cancelled(&options.signal)?;
387
388    let llm_messages = convert_to_llm(&prepared.messages).map_err(CompactionError::from)?;
389    let conversation_text = serialize_conversation(&llm_messages);
390
391    let instructions = if options.replace_instructions {
392        if let Some(custom) = options
393            .custom_instructions
394            .as_deref()
395            .filter(|s| !s.is_empty())
396        {
397            custom.to_owned()
398        } else {
399            BRANCH_SUMMARY_PROMPT.to_owned()
400        }
401    } else if let Some(custom) = options
402        .custom_instructions
403        .as_deref()
404        .filter(|s| !s.is_empty())
405    {
406        format!("{BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: {custom}")
407    } else {
408        BRANCH_SUMMARY_PROMPT.to_owned()
409    };
410
411    let prompt_text =
412        format!("<conversation>\n{conversation_text}\n</conversation>\n\n{instructions}");
413    let summarization_messages = vec![Message::User(UserMessage::new(
414        UserMessageContent::Blocks(vec![UserContent::Text(TextContent::new(prompt_text))]),
415        now_millis(),
416    ))];
417
418    let request_options = StreamOptions {
419        api_key: options.api_key,
420        headers: options.headers,
421        env: options.env,
422        signal: Some(options.signal.clone()),
423        max_tokens: Some(DEFAULT_BRANCH_MAX_TOKENS),
424        ..StreamOptions::default()
425    };
426
427    ensure_not_cancelled(&options.signal)?;
428
429    let response = complete_summarization(
430        &options.model,
431        Context {
432            system_prompt: Some(SUMMARIZATION_SYSTEM_PROMPT.to_owned()),
433            messages: summarization_messages,
434            tools: None,
435        },
436        request_options,
437        &options.stream_fn,
438    )
439    .await?;
440
441    ensure_not_cancelled(&options.signal)?;
442
443    if response.stop_reason == StopReason::Aborted {
444        return Ok(BranchSummaryResult {
445            aborted: Some(true),
446            ..BranchSummaryResult::default()
447        });
448    }
449    if response.stop_reason == StopReason::Error {
450        return Ok(BranchSummaryResult {
451            error: Some(
452                response
453                    .error_message
454                    .clone()
455                    .unwrap_or_else(|| "Summarization failed".to_owned()),
456            ),
457            ..BranchSummaryResult::default()
458        });
459    }
460
461    let mut summary = assistant_text(&response);
462    summary = format!("{BRANCH_SUMMARY_PREAMBLE}{summary}");
463
464    let (read_files, modified_files) = compute_file_lists(&prepared.file_ops);
465    summary.push_str(&format_file_operations(&read_files, &modified_files));
466
467    if summary.is_empty() {
468        "No summary generated".clone_into(&mut summary);
469    }
470
471    Ok(BranchSummaryResult {
472        summary: Some(summary),
473        read_files: Some(read_files),
474        modified_files: Some(modified_files),
475        aborted: None,
476        error: None,
477    })
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::core::compaction::{CompactionSettings, DEFAULT_COMPACTION_SETTINGS};
484    use pi_ai::ProviderError;
485    use pi_ai::{AssistantMessage, ModelInput, TextContent, ToolCall};
486    use serde_json::{Map, json};
487    use std::pin::Pin;
488    use std::sync::Arc;
489
490    fn result_ok<T, E>(result: Result<T, E>) -> T {
491        assert!(result.is_ok());
492        match result {
493            Ok(value) => value,
494            Err(_) => unreachable!(),
495        }
496    }
497
498    fn usage(input: u64, output: u64) -> pi_ai::Usage {
499        pi_ai::Usage {
500            input,
501            output,
502            cache_read: 0,
503            cache_write: 0,
504            cache_write1h: None,
505            reasoning: None,
506            total_tokens: input + output,
507            cost: pi_ai::UsageCost::default(),
508        }
509    }
510
511    fn user_agent(text: &str) -> AgentMessage {
512        AgentMessage::Llm(Box::new(Message::User(UserMessage::new(
513            UserMessageContent::Text(text.to_owned()),
514            1,
515        ))))
516    }
517
518    fn assistant_agent(text: &str) -> AgentMessage {
519        let mut m = AssistantMessage::new("a", "p", "m", 1);
520        m.content = vec![AssistantContent::Text(TextContent::new(text))];
521        m.usage = usage(10, 5);
522        m.stop_reason = StopReason::Stop;
523        AgentMessage::Llm(Box::new(Message::Assistant(m)))
524    }
525
526    fn test_model() -> Model {
527        Model {
528            id: "test".into(),
529            name: "Test".into(),
530            api: "anthropic-messages".into(),
531            provider: "anthropic".into(),
532            base_url: "https://example.test".into(),
533            reasoning: false,
534            thinking_level_map: None,
535            input: vec![ModelInput::Text],
536            cost: pi_ai::ModelCost::default(),
537            context_window: 128_000,
538            max_tokens: 4096,
539            headers: None,
540            compat: None,
541            extra: BTreeMap::new(),
542        }
543    }
544
545    fn capture_stream(
546        captured: &Arc<std::sync::Mutex<Vec<String>>>,
547        maxes: &Arc<std::sync::Mutex<Vec<u64>>>,
548    ) -> SummarizeStreamFn {
549        let captured = Arc::clone(captured);
550        let maxes = Arc::clone(maxes);
551        Arc::new(move |_model, ctx, opts| {
552            let captured = Arc::clone(&captured);
553            let maxes = Arc::clone(&maxes);
554            Box::pin(async move {
555                if let Some(max) = opts.max_tokens {
556                    result_ok(maxes.lock()).push(max);
557                }
558                if let Some(Message::User(user)) = ctx.messages.first() {
559                    let text = match &user.content {
560                        UserMessageContent::Text(text) => text.clone(),
561                        UserMessageContent::Blocks(blocks) => blocks
562                            .iter()
563                            .filter_map(|block| match block {
564                                UserContent::Text(text) => Some(text.text.to_string()),
565                                UserContent::Image(_) => None,
566                            })
567                            .collect::<String>(),
568                    };
569                    result_ok(captured.lock()).push(text);
570                }
571                let mut message = AssistantMessage::new("a", "p", "m", 1);
572                message.content = vec![AssistantContent::Text(TextContent::new("BRANCH"))];
573                message.stop_reason = StopReason::Stop;
574                let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
575                    reason: pi_ai::DoneReason::Stop,
576                    message,
577                })]);
578                Box::pin(stream)
579                    as Pin<
580                        Box<
581                            dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
582                                + Send,
583                        >,
584                    >
585            })
586        })
587    }
588
589    #[test]
590    fn collect_common_ancestor() {
591        let mut sm = result_ok(SessionManager::in_memory(Some("/tmp"), None));
592        let u1 = result_ok(sm.append_message(&user_agent("root")));
593        let a1 = result_ok(sm.append_message(&assistant_agent("a1")));
594        let u2 = result_ok(sm.append_message(&user_agent("branch-a")));
595        // branch back to a1 and create sibling
596        result_ok(sm.branch(&a1));
597        let u3 = result_ok(sm.append_message(&user_agent("branch-b")));
598
599        let collected = collect_entries_for_branch_summary(&sm, Some(&u2), &u3);
600        assert_eq!(collected.common_ancestor_id.as_deref(), Some(a1.as_str()));
601        assert!(!collected.entries.is_empty());
602        assert!(
603            collected
604                .entries
605                .iter()
606                .any(|e| e.id() == Some(u2.as_str()))
607        );
608        assert!(
609            !collected
610                .entries
611                .iter()
612                .any(|e| e.id() == Some(u1.as_str()))
613        );
614    }
615
616    #[test]
617    fn prepare_budget_rescue_and_nested_file_ops() {
618        // Nested branch summary with file details (fromHook=false) must contribute.
619        let nested = result_ok(serde_json::from_value::<SessionEntry>(json!({
620            "type": "branch_summary",
621            "id": "bs1",
622            "parentId": null,
623            "timestamp": "2025-01-01T00:00:00.000Z",
624            "fromId": "root",
625            "summary": "nested",
626            "details": {
627                "readFiles": ["nested-read.txt"],
628                "modifiedFiles": ["nested-mod.txt"]
629            }
630        })));
631
632        let mut asst = AssistantMessage::new("a", "p", "m", 1);
633        asst.content = vec![AssistantContent::ToolCall(ToolCall::new(
634            "1",
635            "read",
636            Map::from_iter([("path".into(), Value::String("fresh.txt".into()))]),
637        ))];
638        let msg_entry = result_ok(serde_json::from_value::<SessionEntry>(json!({
639            "type": "message",
640            "id": "m1",
641            "parentId": "bs1",
642            "timestamp": "2025-01-01T00:00:00.000Z",
643            "message": AgentMessage::Llm(Box::new(Message::Assistant(asst))),
644        })));
645
646        // Huge summary that exceeds tiny budget — should be rescued under 0.9.
647        let big_summary = "S".repeat(400);
648        let compact = result_ok(serde_json::from_value::<SessionEntry>(json!({
649            "type": "compaction",
650            "id": "c1",
651            "parentId": "m1",
652            "timestamp": "2025-01-01T00:00:00.000Z",
653            "summary": big_summary,
654            "firstKeptEntryId": "m1",
655            "tokensBefore": 1,
656        })));
657
658        // Budget just below compact tokens so rescue path triggers.
659        let prep = prepare_branch_entries(&[nested, msg_entry, compact], 50);
660        let (read, modified) = compute_file_lists(&prep.file_ops);
661        assert!(
662            read.iter().any(|p| p == "nested-read.txt") || read.iter().any(|p| p == "fresh.txt")
663        );
664        assert!(modified.iter().any(|p| p == "nested-mod.txt"));
665
666        // fromHook=true details must be ignored.
667        let hooked = result_ok(serde_json::from_value::<SessionEntry>(json!({
668            "type": "branch_summary",
669            "id": "bs2",
670            "parentId": null,
671            "timestamp": "2025-01-01T00:00:00.000Z",
672            "fromId": "root",
673            "summary": "hooked",
674            "fromHook": true,
675            "details": {
676                "readFiles": ["hook-only.txt"],
677                "modifiedFiles": []
678            }
679        })));
680        let prep2 = prepare_branch_entries(&[hooked], 0);
681        let (read2, _) = compute_file_lists(&prep2.file_ops);
682        assert!(!read2.iter().any(|p| p == "hook-only.txt"));
683    }
684
685    #[tokio::test]
686    async fn generate_branch_summary_replace_vs_append_and_caps() {
687        let captured = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
688        let maxes = Arc::new(std::sync::Mutex::new(Vec::<u64>::new()));
689        let stream_fn = capture_stream(&captured, &maxes);
690
691        let entry = result_ok(serde_json::from_value::<SessionEntry>(json!({
692            "type": "message",
693            "id": "u",
694            "parentId": null,
695            "timestamp": "2025-01-01T00:00:00.000Z",
696            "message": user_agent("hello branch"),
697        })));
698
699        // Append custom
700        let result = result_ok(
701            generate_branch_summary(
702                std::slice::from_ref(&entry),
703                GenerateBranchSummaryOptions {
704                    model: test_model(),
705                    api_key: None,
706                    headers: None,
707                    env: None,
708                    signal: CancellationToken::new(),
709                    custom_instructions: Some("focus X".into()),
710                    replace_instructions: false,
711                    reserve_tokens: Some(16_384),
712                    stream_fn: Arc::clone(&stream_fn),
713                },
714            )
715            .await,
716        );
717        assert!(
718            result
719                .summary
720                .as_deref()
721                .unwrap_or("")
722                .contains(BRANCH_SUMMARY_PREAMBLE)
723        );
724        assert!(result.summary.as_deref().unwrap_or("").contains("BRANCH"));
725        {
726            let prompts = result_ok(captured.lock());
727            assert!(prompts[0].contains(BRANCH_SUMMARY_PROMPT));
728            assert!(prompts[0].contains("Additional focus: focus X"));
729        }
730
731        // Replace custom
732        result_ok(captured.lock()).clear();
733        let _ = result_ok(
734            generate_branch_summary(
735                &[entry],
736                GenerateBranchSummaryOptions {
737                    model: test_model(),
738                    api_key: None,
739                    headers: None,
740                    env: None,
741                    signal: CancellationToken::new(),
742                    custom_instructions: Some("ONLY CUSTOM".into()),
743                    replace_instructions: true,
744                    reserve_tokens: None,
745                    stream_fn,
746                },
747            )
748            .await,
749        );
750        {
751            let prompts = result_ok(captured.lock());
752            assert!(prompts[0].contains("ONLY CUSTOM"));
753            assert!(!prompts[0].contains("## Goal"));
754        }
755        assert_eq!(result_ok(maxes.lock())[0], DEFAULT_BRANCH_MAX_TOKENS);
756    }
757
758    // keep DEFAULT_COMPACTION_SETTINGS referenced so settings parity is visible
759    #[test]
760    fn defaults_align() {
761        assert_eq!(
762            DEFAULT_BRANCH_RESERVE_TOKENS,
763            DEFAULT_COMPACTION_SETTINGS.reserve_tokens
764        );
765        let _ = CompactionSettings::default();
766    }
767}