From 4be4b46bd92fddc4e2e689534eeca4ac1d413e1c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 14:51:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20b5-git-aware=20=E2=80=94=20batch=205=20?= =?UTF-8?q?upstream=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/rusty-claude-cli/src/main.rs | 92 +++++++++- .../rusty-claude-cli/tests/compact_output.rs | 159 ++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 rust/crates/rusty-claude-cli/tests/compact_output.rs diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7ab1237..456c21c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -86,6 +86,7 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "--allowed-tools", "--resume", "--print", + "--compact", "-p", ]; @@ -156,8 +157,9 @@ fn run() -> Result<(), Box> { output_format, allowed_tools, permission_mode, + compact, } => LiveCli::new(model, true, allowed_tools, permission_mode)? - .run_turn_with_output(&prompt, output_format)?, + .run_turn_with_output(&prompt, output_format, compact)?, CliAction::Login { output_format } => run_login(output_format)?, CliAction::Logout { output_format } => run_logout(output_format)?, CliAction::Doctor { output_format } => run_doctor(output_format)?, @@ -225,6 +227,7 @@ enum CliAction { output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, + compact: bool, }, Login { output_format: CliOutputFormat, @@ -283,6 +286,7 @@ fn parse_args(args: &[String]) -> Result { let mut wants_help = false; let mut wants_version = false; let mut allowed_tool_values = Vec::new(); + let mut compact = false; let mut rest = Vec::new(); let mut index = 0; @@ -333,6 +337,10 @@ fn parse_args(args: &[String]) -> Result { permission_mode_override = Some(PermissionMode::DangerFullAccess); index += 1; } + "--compact" => { + compact = true; + index += 1; + } "-p" => { // Claw Code compat: -p "prompt" = one-shot prompt let prompt = args[index + 1..].join(" "); @@ -346,6 +354,7 @@ fn parse_args(args: &[String]) -> Result { allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, permission_mode: permission_mode_override .unwrap_or_else(default_permission_mode), + compact, }); } "--print" => { @@ -439,6 +448,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + compact, }), SkillSlashDispatch::Local => Ok(CliAction::Skills { args, @@ -461,6 +471,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + compact, }) } other if other.starts_with('/') => parse_direct_slash_cli_action( @@ -469,6 +480,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + compact, ), _other => Ok(CliAction::Prompt { prompt: rest.join(" "), @@ -476,6 +488,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + compact, }), } } @@ -565,6 +578,7 @@ fn parse_direct_slash_cli_action( output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, + compact: bool, ) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { @@ -590,6 +604,7 @@ fn parse_direct_slash_cli_action( output_format, allowed_tools, permission_mode, + compact, }), SkillSlashDispatch::Local => Ok(CliAction::Skills { args, @@ -3204,13 +3219,28 @@ impl LiveCli { &mut self, input: &str, output_format: CliOutputFormat, + compact: bool, ) -> Result<(), Box> { match output_format { + CliOutputFormat::Text if compact => self.run_prompt_compact(input), CliOutputFormat::Text => self.run_turn(input), CliOutputFormat::Json => self.run_prompt_json(input), } } + fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + let final_text = final_assistant_text(&summary); + println!("{final_text}"); + Ok(()) + } + fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); @@ -7007,6 +7037,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " --output-format FORMAT Non-interactive output format: text or json" )?; + writeln!( + out, + " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" + )?; writeln!( out, " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" @@ -7053,6 +7087,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw --output-format json prompt \"explain src/main.rs\"" )?; + writeln!( + out, + " claw --compact \"summarize Cargo.toml\" | wc -l" + )?; writeln!( out, " claw --allowedTools read,glob \"summarize Cargo.toml\"" @@ -7595,6 +7633,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, + compact: false, } ); } @@ -7618,10 +7657,56 @@ mod tests { output_format: CliOutputFormat::Json, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, + compact: false, } ); } + #[test] + fn parses_compact_flag_for_prompt_mode() { + // given a bare prompt invocation that includes the --compact flag + let _guard = env_lock(); + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + let args = vec![ + "--compact".to_string(), + "summarize".to_string(), + "this".to_string(), + ]; + + // when parse_args interprets the flag + let parsed = parse_args(&args).expect("args should parse"); + + // then compact mode is propagated and other defaults stay unchanged + assert_eq!( + parsed, + CliAction::Prompt { + prompt: "summarize this".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + compact: true, + } + ); + } + + #[test] + fn prompt_subcommand_defaults_compact_to_false() { + // given a `prompt` subcommand invocation without --compact + let _guard = env_lock(); + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + let args = vec!["prompt".to_string(), "hello".to_string()]; + + // when parse_args runs + let parsed = parse_args(&args).expect("args should parse"); + + // then compact stays false (opt-in flag) + match parsed { + CliAction::Prompt { compact, .. } => assert!(!compact), + other => panic!("expected Prompt action, got {other:?}"), + } + } + #[test] fn resolves_model_aliases_in_args() { let _guard = env_lock(); @@ -7640,6 +7725,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, + compact: false, } ); } @@ -7791,6 +7877,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: crate::default_permission_mode(), + compact: false, } ); assert_eq!( @@ -7898,6 +7985,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, + compact: false, } ); } @@ -7962,6 +8050,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: crate::default_permission_mode(), + compact: false, } ); assert_eq!( @@ -7985,6 +8074,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: crate::default_permission_mode(), + compact: false, } ); let error = parse_args(&["/status".to_string()]) diff --git a/rust/crates/rusty-claude-cli/tests/compact_output.rs b/rust/crates/rusty-claude-cli/tests/compact_output.rs new file mode 100644 index 0000000..456862f --- /dev/null +++ b/rust/crates/rusty-claude-cli/tests/compact_output.rs @@ -0,0 +1,159 @@ +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX}; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[test] +fn compact_flag_prints_only_final_assistant_text_without_tool_call_details() { + // given a workspace pointed at the mock Anthropic service and a fixture file + // that the read_file_roundtrip scenario will fetch through a tool call + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + let server = runtime + .block_on(MockAnthropicService::spawn()) + .expect("mock service should start"); + let base_url = server.base_url(); + + let workspace = unique_temp_dir("compact-read-file"); + let config_home = workspace.join("config-home"); + let home = workspace.join("home"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(workspace.join("fixture.txt"), "alpha parity line\n").expect("fixture should write"); + + // when we run claw in compact text mode against a tool-using scenario + let prompt = format!("{SCENARIO_PREFIX}read_file_roundtrip"); + let output = run_claw( + &workspace, + &config_home, + &home, + &base_url, + &[ + "--model", + "sonnet", + "--permission-mode", + "read-only", + "--allowedTools", + "read_file", + "--compact", + &prompt, + ], + ); + + // then the command exits successfully and stdout contains exactly the final + // assistant text with no tool call IDs, JSON envelopes, or spinner output + assert!( + output.status.success(), + "compact run should succeed\nstdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let trimmed = stdout.trim_end_matches('\n'); + assert_eq!( + trimmed, "read_file roundtrip complete: alpha parity line", + "compact stdout should contain only the final assistant text" + ); + assert!( + !stdout.contains("toolu_"), + "compact stdout must not leak tool_use_id ({stdout:?})" + ); + assert!( + !stdout.contains("\"tool_uses\""), + "compact stdout must not leak json envelopes ({stdout:?})" + ); + assert!( + !stdout.contains("Thinking"), + "compact stdout must not include the spinner banner ({stdout:?})" + ); + + fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); +} + +#[test] +fn compact_flag_streaming_text_only_emits_final_message_text() { + // given a workspace pointed at the mock Anthropic service running the + // streaming_text scenario which only emits a single assistant text block + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + let server = runtime + .block_on(MockAnthropicService::spawn()) + .expect("mock service should start"); + let base_url = server.base_url(); + + let workspace = unique_temp_dir("compact-streaming-text"); + let config_home = workspace.join("config-home"); + let home = workspace.join("home"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + + // when we invoke claw with --compact for the streaming text scenario + let prompt = format!("{SCENARIO_PREFIX}streaming_text"); + let output = run_claw( + &workspace, + &config_home, + &home, + &base_url, + &[ + "--model", + "sonnet", + "--permission-mode", + "read-only", + "--compact", + &prompt, + ], + ); + + // then stdout should be exactly the assistant text followed by a newline + assert!( + output.status.success(), + "compact streaming run should succeed\nstdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + assert_eq!( + stdout, "Mock streaming says hello from the parity harness.\n", + "compact streaming stdout should contain only the final assistant text" + ); + + fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); +} + +fn run_claw( + cwd: &std::path::Path, + config_home: &std::path::Path, + home: &std::path::Path, + base_url: &str, + args: &[&str], +) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); + command + .current_dir(cwd) + .env_clear() + .env("ANTHROPIC_API_KEY", "test-compact-key") + .env("ANTHROPIC_BASE_URL", base_url) + .env("CLAW_CONFIG_HOME", config_home) + .env("HOME", home) + .env("NO_COLOR", "1") + .env("PATH", "/usr/bin:/bin") + .args(args); + command.output().expect("claw should launch") +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_millis(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "claw-compact-{label}-{}-{millis}-{counter}", + std::process::id() + )) +}