diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 34bf9ad..f4fdc2d 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1333,6 +1333,41 @@ mod tests { SlashCommand::parse("/debug-tool-call"), Some(SlashCommand::DebugToolCall) ); + assert_eq!( + SlashCommand::parse("/bughunter runtime"), + Some(SlashCommand::Bughunter { + scope: Some("runtime".to_string()) + }) + ); + assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); + assert_eq!( + SlashCommand::parse("/pr ready for review"), + Some(SlashCommand::Pr { + context: Some("ready for review".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/issue flaky test"), + Some(SlashCommand::Issue { + context: Some("flaky test".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/ultraplan ship both features"), + Some(SlashCommand::Ultraplan { + task: Some("ship both features".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/teleport conversation.rs"), + Some(SlashCommand::Teleport { + target: Some("conversation.rs".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/debug-tool-call"), + Some(SlashCommand::DebugToolCall) + ); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { @@ -1520,6 +1555,22 @@ mod tests { handle_slash_command("/debug-tool-call", &session, CompactionConfig::default()) .is_none() ); + assert!( + handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() + ); + assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none() + ); + assert!( + handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none() + ); + assert!( + handle_slash_command("/debug-tool-call", &session, CompactionConfig::default()) + .is_none() + ); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 8411b8d..da12a8b 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -10,6 +10,9 @@ use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; +const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; +const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiRequest { pub system_prompt: Vec, @@ -86,6 +89,12 @@ pub struct TurnSummary { pub tool_results: Vec, pub iterations: usize, pub usage: TokenUsage, + pub auto_compaction: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutoCompactionEvent { + pub removed_message_count: usize, } pub struct ConversationRuntime { @@ -97,6 +106,7 @@ pub struct ConversationRuntime { max_iterations: usize, usage_tracker: UsageTracker, hook_runner: HookRunner, + auto_compaction_input_tokens_threshold: u32, } impl ConversationRuntime @@ -141,6 +151,7 @@ where max_iterations: usize::MAX, usage_tracker, hook_runner: HookRunner::from_feature_config(&feature_config), + auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), } } @@ -150,6 +161,12 @@ where self } + #[must_use] + pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self { + self.auto_compaction_input_tokens_threshold = threshold; + self + } + pub fn run_turn( &mut self, user_input: impl Into, @@ -254,11 +271,14 @@ where } } + let auto_compaction = self.maybe_auto_compact(); + Ok(TurnSummary { assistant_messages, tool_results, iterations, usage: self.usage_tracker.cumulative_usage(), + auto_compaction, }) } @@ -286,6 +306,48 @@ where pub fn into_session(self) -> Session { self.session } + + fn maybe_auto_compact(&mut self) -> Option { + if self.usage_tracker.cumulative_usage().input_tokens + < self.auto_compaction_input_tokens_threshold + { + return None; + } + + let result = compact_session( + &self.session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + + if result.removed_message_count == 0 { + return None; + } + + self.session = result.compacted_session; + Some(AutoCompactionEvent { + removed_message_count: result.removed_message_count, + }) + } +} + +#[must_use] +pub fn auto_compaction_threshold_from_env() -> u32 { + parse_auto_compaction_threshold( + std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR) + .ok() + .as_deref(), + ) +} + +#[must_use] +fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 { + value + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|threshold| *threshold > 0) + .unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD) } fn build_assistant_message( @@ -396,8 +458,9 @@ impl ToolExecutor for StaticToolExecutor { #[cfg(test)] mod tests { use super::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, - StaticToolExecutor, + parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent, + AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD, }; use crate::compact::CompactionConfig; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; @@ -508,6 +571,7 @@ mod tests { assert_eq!(summary.tool_results.len(), 1); assert_eq!(runtime.session().messages.len(), 4); assert_eq!(summary.usage.output_tokens, 10); + assert_eq!(summary.auto_compaction, None); assert!(matches!( runtime.session().messages[1].blocks[1], ContentBlock::ToolUse { .. } @@ -798,4 +862,111 @@ mod tests { fn shell_snippet(script: &str) -> String { script.to_string() } + + #[test] + fn auto_compacts_when_cumulative_input_threshold_is_crossed() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 120_000, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let session = Session { + version: 1, + messages: vec![ + crate::session::ConversationMessage::user_text("one"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "two".to_string(), + }]), + crate::session::ConversationMessage::user_text("three"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "four".to_string(), + }]), + ], + }; + + let mut runtime = ConversationRuntime::new( + session, + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + + assert_eq!( + summary.auto_compaction, + Some(AutoCompactionEvent { + removed_message_count: 2, + }) + ); + assert_eq!(runtime.session().messages[0].role, MessageRole::System); + } + + #[test] + fn skips_auto_compaction_below_threshold() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 99_999, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let mut runtime = ConversationRuntime::new( + Session::new(), + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + assert_eq!(summary.auto_compaction, None); + assert_eq!(runtime.session().messages.len(), 2); + } + + #[test] + fn auto_compaction_threshold_defaults_and_parses_values() { + assert_eq!( + parse_auto_compaction_threshold(None), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321); + assert_eq!( + parse_auto_compaction_threshold(Some("not-a-number")), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 03d4191..68df55f 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -31,8 +31,8 @@ pub use config::{ RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, - ToolError, ToolExecutor, TurnSummary, + auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, + ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary, }; pub use file_ops::{ edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 2619c3f..ce682c6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -788,6 +788,10 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo } } +fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { parse_git_status_metadata_for( &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), @@ -1143,13 +1147,19 @@ impl LiveCli { let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { - Ok(_) => { + Ok(summary) => { spinner.finish( "✨ Done", TerminalRenderer::new().color_theme(), &mut stdout, )?; println!(); + if let Some(event) = summary.auto_compaction { + println!( + "{}", + format_auto_compaction_notice(event.removed_message_count) + ); + } self.persist_session()?; Ok(()) } @@ -1197,6 +1207,10 @@ impl LiveCli { "message": final_assistant_text(&summary), "model": self.model, "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| json!({ + "removed_messages": event.removed_message_count, + "notice": format_auto_compaction_notice(event.removed_message_count), + })), "tool_uses": collect_tool_uses(&summary), "tool_results": collect_tool_results(&summary), "usage": {