feat: b5-git-aware — batch 5 upstream parity

This commit is contained in:
YeonGyu-Kim
2026-04-07 14:51:12 +09:00
parent 506ff55e53
commit 4be4b46bd9
2 changed files with 250 additions and 1 deletions

View File

@@ -86,6 +86,7 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"--allowed-tools",
"--resume",
"--print",
"--compact",
"-p",
];
@@ -156,8 +157,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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<AllowedToolSet>,
permission_mode: PermissionMode,
compact: bool,
},
Login {
output_format: CliOutputFormat,
@@ -283,6 +286,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
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<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
compact,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
@@ -461,6 +471,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
compact,
),
_other => Ok(CliAction::Prompt {
prompt: rest.join(" "),
@@ -476,6 +488,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
compact,
}),
}
}
@@ -565,6 +578,7 @@ fn parse_direct_slash_cli_action(
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
compact: bool,
) -> Result<CliAction, String> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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()])

View File

@@ -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()
))
}