compact.rs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  2. const COMPACT_CONTINUATION_PREAMBLE: &str =
  3. "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
  4. const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
  5. const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
  6. /// Thresholds controlling when and how a session is compacted.
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  8. pub struct CompactionConfig {
  9. pub preserve_recent_messages: usize,
  10. pub max_estimated_tokens: usize,
  11. }
  12. impl Default for CompactionConfig {
  13. fn default() -> Self {
  14. Self {
  15. preserve_recent_messages: 4,
  16. max_estimated_tokens: 10_000,
  17. }
  18. }
  19. }
  20. /// Result of compacting a session into a summary plus preserved tail messages.
  21. #[derive(Debug, Clone, PartialEq, Eq)]
  22. pub struct CompactionResult {
  23. pub summary: String,
  24. pub formatted_summary: String,
  25. pub compacted_session: Session,
  26. pub removed_message_count: usize,
  27. }
  28. /// Roughly estimates the token footprint of the current session transcript.
  29. #[must_use]
  30. pub fn estimate_session_tokens(session: &Session) -> usize {
  31. session.messages.iter().map(estimate_message_tokens).sum()
  32. }
  33. /// Returns `true` when the session exceeds the configured compaction budget.
  34. #[must_use]
  35. pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
  36. let start = compacted_summary_prefix_len(session);
  37. let compactable = &session.messages[start..];
  38. compactable.len() > config.preserve_recent_messages
  39. && compactable
  40. .iter()
  41. .map(estimate_message_tokens)
  42. .sum::<usize>()
  43. >= config.max_estimated_tokens
  44. }
  45. /// Normalizes a compaction summary into user-facing continuation text.
  46. #[must_use]
  47. pub fn format_compact_summary(summary: &str) -> String {
  48. let without_analysis = strip_tag_block(summary, "analysis");
  49. let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
  50. without_analysis.replace(
  51. &format!("<summary>{content}</summary>"),
  52. &format!("Summary:\n{}", content.trim()),
  53. )
  54. } else {
  55. without_analysis
  56. };
  57. collapse_blank_lines(&formatted).trim().to_string()
  58. }
  59. /// Builds the synthetic system message used after session compaction.
  60. #[must_use]
  61. pub fn get_compact_continuation_message(
  62. summary: &str,
  63. suppress_follow_up_questions: bool,
  64. recent_messages_preserved: bool,
  65. ) -> String {
  66. let mut base = format!(
  67. "{COMPACT_CONTINUATION_PREAMBLE}{}",
  68. format_compact_summary(summary)
  69. );
  70. if recent_messages_preserved {
  71. base.push_str("\n\n");
  72. base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
  73. }
  74. if suppress_follow_up_questions {
  75. base.push('\n');
  76. base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
  77. }
  78. base
  79. }
  80. /// Compacts a session by summarizing older messages and preserving the recent tail.
  81. #[must_use]
  82. pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
  83. if !should_compact(session, config) {
  84. return CompactionResult {
  85. summary: String::new(),
  86. formatted_summary: String::new(),
  87. compacted_session: session.clone(),
  88. removed_message_count: 0,
  89. };
  90. }
  91. let existing_summary = session
  92. .messages
  93. .first()
  94. .and_then(extract_existing_compacted_summary);
  95. let compacted_prefix_len = usize::from(existing_summary.is_some());
  96. let keep_from = session
  97. .messages
  98. .len()
  99. .saturating_sub(config.preserve_recent_messages);
  100. let removed = &session.messages[compacted_prefix_len..keep_from];
  101. let preserved = session.messages[keep_from..].to_vec();
  102. let summary =
  103. merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
  104. let formatted_summary = format_compact_summary(&summary);
  105. let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
  106. let mut compacted_messages = vec![ConversationMessage {
  107. role: MessageRole::System,
  108. blocks: vec![ContentBlock::Text { text: continuation }],
  109. usage: None,
  110. }];
  111. compacted_messages.extend(preserved);
  112. let mut compacted_session = session.clone();
  113. compacted_session.messages = compacted_messages;
  114. compacted_session.record_compaction(summary.clone(), removed.len());
  115. CompactionResult {
  116. summary,
  117. formatted_summary,
  118. compacted_session,
  119. removed_message_count: removed.len(),
  120. }
  121. }
  122. fn compacted_summary_prefix_len(session: &Session) -> usize {
  123. usize::from(
  124. session
  125. .messages
  126. .first()
  127. .and_then(extract_existing_compacted_summary)
  128. .is_some(),
  129. )
  130. }
  131. fn summarize_messages(messages: &[ConversationMessage]) -> String {
  132. let user_messages = messages
  133. .iter()
  134. .filter(|message| message.role == MessageRole::User)
  135. .count();
  136. let assistant_messages = messages
  137. .iter()
  138. .filter(|message| message.role == MessageRole::Assistant)
  139. .count();
  140. let tool_messages = messages
  141. .iter()
  142. .filter(|message| message.role == MessageRole::Tool)
  143. .count();
  144. let mut tool_names = messages
  145. .iter()
  146. .flat_map(|message| message.blocks.iter())
  147. .filter_map(|block| match block {
  148. ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
  149. ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
  150. ContentBlock::Text { .. } => None,
  151. })
  152. .collect::<Vec<_>>();
  153. tool_names.sort_unstable();
  154. tool_names.dedup();
  155. let mut lines = vec![
  156. "<summary>".to_string(),
  157. "Conversation summary:".to_string(),
  158. format!(
  159. "- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
  160. messages.len(),
  161. user_messages,
  162. assistant_messages,
  163. tool_messages
  164. ),
  165. ];
  166. if !tool_names.is_empty() {
  167. lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
  168. }
  169. let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
  170. if !recent_user_requests.is_empty() {
  171. lines.push("- Recent user requests:".to_string());
  172. lines.extend(
  173. recent_user_requests
  174. .into_iter()
  175. .map(|request| format!(" - {request}")),
  176. );
  177. }
  178. let pending_work = infer_pending_work(messages);
  179. if !pending_work.is_empty() {
  180. lines.push("- Pending work:".to_string());
  181. lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
  182. }
  183. let key_files = collect_key_files(messages);
  184. if !key_files.is_empty() {
  185. lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
  186. }
  187. if let Some(current_work) = infer_current_work(messages) {
  188. lines.push(format!("- Current work: {current_work}"));
  189. }
  190. lines.push("- Key timeline:".to_string());
  191. for message in messages {
  192. let role = match message.role {
  193. MessageRole::System => "system",
  194. MessageRole::User => "user",
  195. MessageRole::Assistant => "assistant",
  196. MessageRole::Tool => "tool",
  197. };
  198. let content = message
  199. .blocks
  200. .iter()
  201. .map(summarize_block)
  202. .collect::<Vec<_>>()
  203. .join(" | ");
  204. lines.push(format!(" - {role}: {content}"));
  205. }
  206. lines.push("</summary>".to_string());
  207. lines.join("\n")
  208. }
  209. fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
  210. let Some(existing_summary) = existing_summary else {
  211. return new_summary.to_string();
  212. };
  213. let previous_highlights = extract_summary_highlights(existing_summary);
  214. let new_formatted_summary = format_compact_summary(new_summary);
  215. let new_highlights = extract_summary_highlights(&new_formatted_summary);
  216. let new_timeline = extract_summary_timeline(&new_formatted_summary);
  217. let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
  218. if !previous_highlights.is_empty() {
  219. lines.push("- Previously compacted context:".to_string());
  220. lines.extend(
  221. previous_highlights
  222. .into_iter()
  223. .map(|line| format!(" {line}")),
  224. );
  225. }
  226. if !new_highlights.is_empty() {
  227. lines.push("- Newly compacted context:".to_string());
  228. lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
  229. }
  230. if !new_timeline.is_empty() {
  231. lines.push("- Key timeline:".to_string());
  232. lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
  233. }
  234. lines.push("</summary>".to_string());
  235. lines.join("\n")
  236. }
  237. fn summarize_block(block: &ContentBlock) -> String {
  238. let raw = match block {
  239. ContentBlock::Text { text } => text.clone(),
  240. ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
  241. ContentBlock::ToolResult {
  242. tool_name,
  243. output,
  244. is_error,
  245. ..
  246. } => format!(
  247. "tool_result {tool_name}: {}{output}",
  248. if *is_error { "error " } else { "" }
  249. ),
  250. };
  251. truncate_summary(&raw, 160)
  252. }
  253. fn collect_recent_role_summaries(
  254. messages: &[ConversationMessage],
  255. role: MessageRole,
  256. limit: usize,
  257. ) -> Vec<String> {
  258. messages
  259. .iter()
  260. .filter(|message| message.role == role)
  261. .rev()
  262. .filter_map(|message| first_text_block(message))
  263. .take(limit)
  264. .map(|text| truncate_summary(text, 160))
  265. .collect::<Vec<_>>()
  266. .into_iter()
  267. .rev()
  268. .collect()
  269. }
  270. fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
  271. messages
  272. .iter()
  273. .rev()
  274. .filter_map(first_text_block)
  275. .filter(|text| {
  276. let lowered = text.to_ascii_lowercase();
  277. lowered.contains("todo")
  278. || lowered.contains("next")
  279. || lowered.contains("pending")
  280. || lowered.contains("follow up")
  281. || lowered.contains("remaining")
  282. })
  283. .take(3)
  284. .map(|text| truncate_summary(text, 160))
  285. .collect::<Vec<_>>()
  286. .into_iter()
  287. .rev()
  288. .collect()
  289. }
  290. fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
  291. let mut files = messages
  292. .iter()
  293. .flat_map(|message| message.blocks.iter())
  294. .map(|block| match block {
  295. ContentBlock::Text { text } => text.as_str(),
  296. ContentBlock::ToolUse { input, .. } => input.as_str(),
  297. ContentBlock::ToolResult { output, .. } => output.as_str(),
  298. })
  299. .flat_map(extract_file_candidates)
  300. .collect::<Vec<_>>();
  301. files.sort();
  302. files.dedup();
  303. files.into_iter().take(8).collect()
  304. }
  305. fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
  306. messages
  307. .iter()
  308. .rev()
  309. .filter_map(first_text_block)
  310. .find(|text| !text.trim().is_empty())
  311. .map(|text| truncate_summary(text, 200))
  312. }
  313. fn first_text_block(message: &ConversationMessage) -> Option<&str> {
  314. message.blocks.iter().find_map(|block| match block {
  315. ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
  316. ContentBlock::ToolUse { .. }
  317. | ContentBlock::ToolResult { .. }
  318. | ContentBlock::Text { .. } => None,
  319. })
  320. }
  321. fn has_interesting_extension(candidate: &str) -> bool {
  322. std::path::Path::new(candidate)
  323. .extension()
  324. .and_then(|extension| extension.to_str())
  325. .is_some_and(|extension| {
  326. ["rs", "ts", "tsx", "js", "json", "md"]
  327. .iter()
  328. .any(|expected| extension.eq_ignore_ascii_case(expected))
  329. })
  330. }
  331. fn extract_file_candidates(content: &str) -> Vec<String> {
  332. content
  333. .split_whitespace()
  334. .filter_map(|token| {
  335. let candidate = token.trim_matches(|char: char| {
  336. matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
  337. });
  338. if candidate.contains('/') && has_interesting_extension(candidate) {
  339. Some(candidate.to_string())
  340. } else {
  341. None
  342. }
  343. })
  344. .collect()
  345. }
  346. fn truncate_summary(content: &str, max_chars: usize) -> String {
  347. if content.chars().count() <= max_chars {
  348. return content.to_string();
  349. }
  350. let mut truncated = content.chars().take(max_chars).collect::<String>();
  351. truncated.push('…');
  352. truncated
  353. }
  354. fn estimate_message_tokens(message: &ConversationMessage) -> usize {
  355. message
  356. .blocks
  357. .iter()
  358. .map(|block| match block {
  359. ContentBlock::Text { text } => text.len() / 4 + 1,
  360. ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
  361. ContentBlock::ToolResult {
  362. tool_name, output, ..
  363. } => (tool_name.len() + output.len()) / 4 + 1,
  364. })
  365. .sum()
  366. }
  367. fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
  368. let start = format!("<{tag}>");
  369. let end = format!("</{tag}>");
  370. let start_index = content.find(&start)? + start.len();
  371. let end_index = content[start_index..].find(&end)? + start_index;
  372. Some(content[start_index..end_index].to_string())
  373. }
  374. fn strip_tag_block(content: &str, tag: &str) -> String {
  375. let start = format!("<{tag}>");
  376. let end = format!("</{tag}>");
  377. if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
  378. let end_index = end_index_rel + end.len();
  379. let mut stripped = String::new();
  380. stripped.push_str(&content[..start_index]);
  381. stripped.push_str(&content[end_index..]);
  382. stripped
  383. } else {
  384. content.to_string()
  385. }
  386. }
  387. fn collapse_blank_lines(content: &str) -> String {
  388. let mut result = String::new();
  389. let mut last_blank = false;
  390. for line in content.lines() {
  391. let is_blank = line.trim().is_empty();
  392. if is_blank && last_blank {
  393. continue;
  394. }
  395. result.push_str(line);
  396. result.push('\n');
  397. last_blank = is_blank;
  398. }
  399. result
  400. }
  401. fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
  402. if message.role != MessageRole::System {
  403. return None;
  404. }
  405. let text = first_text_block(message)?;
  406. let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
  407. let summary = summary
  408. .split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
  409. .map_or(summary, |(value, _)| value);
  410. let summary = summary
  411. .split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
  412. .map_or(summary, |(value, _)| value);
  413. Some(summary.trim().to_string())
  414. }
  415. fn extract_summary_highlights(summary: &str) -> Vec<String> {
  416. let mut lines = Vec::new();
  417. let mut in_timeline = false;
  418. for line in format_compact_summary(summary).lines() {
  419. let trimmed = line.trim_end();
  420. if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
  421. continue;
  422. }
  423. if trimmed == "- Key timeline:" {
  424. in_timeline = true;
  425. continue;
  426. }
  427. if in_timeline {
  428. continue;
  429. }
  430. lines.push(trimmed.to_string());
  431. }
  432. lines
  433. }
  434. fn extract_summary_timeline(summary: &str) -> Vec<String> {
  435. let mut lines = Vec::new();
  436. let mut in_timeline = false;
  437. for line in format_compact_summary(summary).lines() {
  438. let trimmed = line.trim_end();
  439. if trimmed == "- Key timeline:" {
  440. in_timeline = true;
  441. continue;
  442. }
  443. if !in_timeline {
  444. continue;
  445. }
  446. if trimmed.is_empty() {
  447. break;
  448. }
  449. lines.push(trimmed.to_string());
  450. }
  451. lines
  452. }
  453. #[cfg(test)]
  454. mod tests {
  455. use super::{
  456. collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
  457. get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
  458. };
  459. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  460. #[test]
  461. fn formats_compact_summary_like_upstream() {
  462. let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
  463. assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
  464. }
  465. #[test]
  466. fn leaves_small_sessions_unchanged() {
  467. let mut session = Session::new();
  468. session.messages = vec![ConversationMessage::user_text("hello")];
  469. let result = compact_session(&session, CompactionConfig::default());
  470. assert_eq!(result.removed_message_count, 0);
  471. assert_eq!(result.compacted_session, session);
  472. assert!(result.summary.is_empty());
  473. assert!(result.formatted_summary.is_empty());
  474. }
  475. #[test]
  476. fn compacts_older_messages_into_a_system_summary() {
  477. let mut session = Session::new();
  478. session.messages = vec![
  479. ConversationMessage::user_text("one ".repeat(200)),
  480. ConversationMessage::assistant(vec![ContentBlock::Text {
  481. text: "two ".repeat(200),
  482. }]),
  483. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  484. ConversationMessage {
  485. role: MessageRole::Assistant,
  486. blocks: vec![ContentBlock::Text {
  487. text: "recent".to_string(),
  488. }],
  489. usage: None,
  490. },
  491. ];
  492. let result = compact_session(
  493. &session,
  494. CompactionConfig {
  495. preserve_recent_messages: 2,
  496. max_estimated_tokens: 1,
  497. },
  498. );
  499. assert_eq!(result.removed_message_count, 2);
  500. assert_eq!(
  501. result.compacted_session.messages[0].role,
  502. MessageRole::System
  503. );
  504. assert!(matches!(
  505. &result.compacted_session.messages[0].blocks[0],
  506. ContentBlock::Text { text } if text.contains("Summary:")
  507. ));
  508. assert!(result.formatted_summary.contains("Scope:"));
  509. assert!(result.formatted_summary.contains("Key timeline:"));
  510. assert!(should_compact(
  511. &session,
  512. CompactionConfig {
  513. preserve_recent_messages: 2,
  514. max_estimated_tokens: 1,
  515. }
  516. ));
  517. assert!(
  518. estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
  519. );
  520. }
  521. #[test]
  522. fn keeps_previous_compacted_context_when_compacting_again() {
  523. let mut initial_session = Session::new();
  524. initial_session.messages = vec![
  525. ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
  526. ConversationMessage::assistant(vec![ContentBlock::Text {
  527. text: "I will inspect the compact flow.".to_string(),
  528. }]),
  529. ConversationMessage::user_text("Also update rust/crates/runtime/src/conversation.rs"),
  530. ConversationMessage::assistant(vec![ContentBlock::Text {
  531. text: "Next: preserve prior summary context during auto compact.".to_string(),
  532. }]),
  533. ];
  534. let config = CompactionConfig {
  535. preserve_recent_messages: 2,
  536. max_estimated_tokens: 1,
  537. };
  538. let first = compact_session(&initial_session, config);
  539. let mut follow_up_messages = first.compacted_session.messages.clone();
  540. follow_up_messages.extend([
  541. ConversationMessage::user_text("Please add regression tests for compaction."),
  542. ConversationMessage::assistant(vec![ContentBlock::Text {
  543. text: "Working on regression coverage now.".to_string(),
  544. }]),
  545. ]);
  546. let mut second_session = Session::new();
  547. second_session.messages = follow_up_messages;
  548. let second = compact_session(&second_session, config);
  549. assert!(second
  550. .formatted_summary
  551. .contains("Previously compacted context:"));
  552. assert!(second
  553. .formatted_summary
  554. .contains("Scope: 2 earlier messages compacted"));
  555. assert!(second
  556. .formatted_summary
  557. .contains("Newly compacted context:"));
  558. assert!(second
  559. .formatted_summary
  560. .contains("Also update rust/crates/runtime/src/conversation.rs"));
  561. assert!(matches!(
  562. &second.compacted_session.messages[0].blocks[0],
  563. ContentBlock::Text { text }
  564. if text.contains("Previously compacted context:")
  565. && text.contains("Newly compacted context:")
  566. ));
  567. assert!(matches!(
  568. &second.compacted_session.messages[1].blocks[0],
  569. ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
  570. ));
  571. }
  572. #[test]
  573. fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
  574. let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
  575. let mut session = Session::new();
  576. session.messages = vec![
  577. ConversationMessage {
  578. role: MessageRole::System,
  579. blocks: vec![ContentBlock::Text {
  580. text: get_compact_continuation_message(summary, true, true),
  581. }],
  582. usage: None,
  583. },
  584. ConversationMessage::user_text("tiny"),
  585. ConversationMessage::assistant(vec![ContentBlock::Text {
  586. text: "recent".to_string(),
  587. }]),
  588. ];
  589. assert!(!should_compact(
  590. &session,
  591. CompactionConfig {
  592. preserve_recent_messages: 2,
  593. max_estimated_tokens: 1,
  594. }
  595. ));
  596. }
  597. #[test]
  598. fn truncates_long_blocks_in_summary() {
  599. let summary = super::summarize_block(&ContentBlock::Text {
  600. text: "x".repeat(400),
  601. });
  602. assert!(summary.ends_with('…'));
  603. assert!(summary.chars().count() <= 161);
  604. }
  605. #[test]
  606. fn extracts_key_files_from_message_content() {
  607. let files = collect_key_files(&[ConversationMessage::user_text(
  608. "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
  609. )]);
  610. assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
  611. assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
  612. }
  613. #[test]
  614. fn infers_pending_work_from_recent_messages() {
  615. let pending = infer_pending_work(&[
  616. ConversationMessage::user_text("done"),
  617. ConversationMessage::assistant(vec![ContentBlock::Text {
  618. text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
  619. }]),
  620. ]);
  621. assert_eq!(pending.len(), 1);
  622. assert!(pending[0].contains("Next: update tests"));
  623. }
  624. }