From 8465b6923be4e31c18715e688c06a49ca19a9c11 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:34:56 +0000 Subject: [PATCH] Preserve actionable state in compacted Rust sessions This upgrades Rust session compaction so summaries carry more than a flat timeline. The compacted state now calls out recent user requests, pending work signals, key files, and the current work focus so resumed sessions retain stronger execution continuity. The change stays deterministic and local while moving the compact output closer to session-memory style handoff value. Constraint: Keep compaction local and deterministic rather than introducing API-side summarization Constraint: Preserve the existing resumable system-summary mechanism and compact command flow Rejected: Add a full session-memory background extractor now | larger runtime change than needed for this incremental parity pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep future compaction enrichments biased toward actionable state transfer, not just verbose recap Tested: cargo fmt; cargo clippy --all-targets --all-features -- -D warnings; cargo test -q Not-tested: Long real-world sessions with deeply nested tool/result payloads --- rust/crates/runtime/src/compact.rs | 150 ++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 2 deletions(-) diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 42e63ed..e227019 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -152,6 +152,31 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String { lines.push(format!("- Tools mentioned: {}.", tool_names.join(", "))); } + let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3); + if !recent_user_requests.is_empty() { + lines.push("- Recent user requests:".to_string()); + lines.extend( + recent_user_requests + .into_iter() + .map(|request| format!(" - {request}")), + ); + } + + let pending_work = infer_pending_work(messages); + if !pending_work.is_empty() { + lines.push("- Pending work:".to_string()); + lines.extend(pending_work.into_iter().map(|item| format!(" - {item}"))); + } + + let key_files = collect_key_files(messages); + if !key_files.is_empty() { + lines.push(format!("- Key files referenced: {}.", key_files.join(", "))); + } + + if let Some(current_work) = infer_current_work(messages) { + lines.push(format!("- Current work: {current_work}")); + } + lines.push("- Key timeline:".to_string()); for message in messages { let role = match message.role { @@ -189,6 +214,106 @@ fn summarize_block(block: &ContentBlock) -> String { truncate_summary(&raw, 160) } +fn collect_recent_role_summaries( + messages: &[ConversationMessage], + role: MessageRole, + limit: usize, +) -> Vec { + messages + .iter() + .filter(|message| message.role == role) + .rev() + .filter_map(|message| first_text_block(message)) + .take(limit) + .map(|text| truncate_summary(text, 160)) + .collect::>() + .into_iter() + .rev() + .collect() +} + +fn infer_pending_work(messages: &[ConversationMessage]) -> Vec { + messages + .iter() + .rev() + .filter_map(first_text_block) + .filter(|text| { + let lowered = text.to_ascii_lowercase(); + lowered.contains("todo") + || lowered.contains("next") + || lowered.contains("pending") + || lowered.contains("follow up") + || lowered.contains("remaining") + }) + .take(3) + .map(|text| truncate_summary(text, 160)) + .collect::>() + .into_iter() + .rev() + .collect() +} + +fn collect_key_files(messages: &[ConversationMessage]) -> Vec { + let mut files = messages + .iter() + .flat_map(|message| message.blocks.iter()) + .map(|block| match block { + ContentBlock::Text { text } => text.as_str(), + ContentBlock::ToolUse { input, .. } => input.as_str(), + ContentBlock::ToolResult { output, .. } => output.as_str(), + }) + .flat_map(extract_file_candidates) + .collect::>(); + files.sort(); + files.dedup(); + files.into_iter().take(8).collect() +} + +fn infer_current_work(messages: &[ConversationMessage]) -> Option { + messages + .iter() + .rev() + .filter_map(first_text_block) + .find(|text| !text.trim().is_empty()) + .map(|text| truncate_summary(text, 200)) +} + +fn first_text_block(message: &ConversationMessage) -> Option<&str> { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()), + ContentBlock::ToolUse { .. } + | ContentBlock::ToolResult { .. } + | ContentBlock::Text { .. } => None, + }) +} + +fn has_interesting_extension(candidate: &str) -> bool { + std::path::Path::new(candidate) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + ["rs", "ts", "tsx", "js", "json", "md"] + .iter() + .any(|expected| extension.eq_ignore_ascii_case(expected)) + }) +} + +fn extract_file_candidates(content: &str) -> Vec { + content + .split_whitespace() + .filter_map(|token| { + let candidate = token.trim_matches(|char: char| { + matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`') + }); + if candidate.contains('/') && has_interesting_extension(candidate) { + Some(candidate.to_string()) + } else { + None + } + }) + .collect() +} + fn truncate_summary(content: &str, max_chars: usize) -> String { if content.chars().count() <= max_chars { return content.to_string(); @@ -252,8 +377,8 @@ fn collapse_blank_lines(content: &str) -> String { #[cfg(test)] mod tests { use super::{ - compact_session, estimate_session_tokens, format_compact_summary, should_compact, - CompactionConfig, + collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, + infer_pending_work, should_compact, CompactionConfig, }; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; @@ -336,4 +461,25 @@ mod tests { assert!(summary.ends_with('…')); assert!(summary.chars().count() <= 161); } + + #[test] + fn extracts_key_files_from_message_content() { + let files = collect_key_files(&[ConversationMessage::user_text( + "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.", + )]); + assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string())); + assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string())); + } + + #[test] + fn infers_pending_work_from_recent_messages() { + let pending = infer_pending_work(&[ + ConversationMessage::user_text("done"), + ConversationMessage::assistant(vec![ContentBlock::Text { + text: "Next: update tests and follow up on remaining CLI polish.".to_string(), + }]), + ]); + assert_eq!(pending.len(), 1); + assert!(pending[0].contains("Next: update tests")); + } }