1use 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
30pub const DEFAULT_BRANCH_MAX_TOKENS: u64 = 2048;
32
33pub const DEFAULT_BRANCH_CONTEXT_WINDOW: u64 = 128_000;
35
36pub const DEFAULT_BRANCH_RESERVE_TOKENS: u64 = 16_384;
38
39pub const BRANCH_SUMMARY_PREAMBLE: &str = "The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n";
41
42pub 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct BranchSummaryResult {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub summary: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub read_files: Option<Vec<String>>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub modified_files: Option<Vec<String>>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub aborted: Option<bool>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub error: Option<String>,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct BranchSummaryDetails {
70 pub read_files: Vec<String>,
72 pub modified_files: Vec<String>,
74}
75
76#[derive(Clone, Debug)]
78pub struct BranchPreparation {
79 pub messages: Vec<AgentMessage>,
81 pub file_ops: FileOperations,
83 pub total_tokens: u64,
85}
86
87#[derive(Clone, Debug)]
89pub struct CollectEntriesResult {
90 pub entries: Vec<SessionEntry>,
92 pub common_ancestor_id: Option<String>,
94}
95
96pub struct GenerateBranchSummaryOptions {
98 pub model: Model,
100 pub api_key: Option<String>,
102 pub headers: Option<BTreeMap<String, Option<String>>>,
104 pub env: Option<BTreeMap<String, String>>,
106 pub signal: CancellationToken,
108 pub custom_instructions: Option<String>,
110 pub replace_instructions: bool,
112 pub reserve_tokens: Option<u64>,
114 pub stream_fn: SummarizeStreamFn,
116}
117
118#[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#[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 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 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 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
356pub 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 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 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 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 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 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 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 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 #[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}