feat: b5-multi-provider — batch 5 upstream parity

This commit is contained in:
YeonGyu-Kim
2026-04-07 14:51:12 +09:00
parent b216f9ce05
commit b8f76442e2

View File

@@ -164,6 +164,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Doctor { output_format } => run_doctor(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Export {
session_reference,
output_path,
output_format,
} => run_export(&session_reference, output_path.as_deref(), output_format)?,
CliAction::Repl {
model,
allowed_tools,
@@ -241,6 +246,11 @@ enum CliAction {
Init {
output_format: CliOutputFormat,
},
Export {
session_reference: String,
output_path: Option<PathBuf>,
output_format: CliOutputFormat,
},
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
@@ -460,6 +470,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"login" => Ok(CliAction::Login { output_format }),
"logout" => Ok(CliAction::Logout { output_format }),
"init" => Ok(CliAction::Init { output_format }),
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
@@ -548,6 +559,7 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
| "logout"
| "init"
| "prompt"
| "export"
) {
return None;
}
@@ -912,6 +924,58 @@ fn parse_system_prompt_args(
})
}
fn parse_export_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
let mut output_path: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--session" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --session".to_string())?;
session_reference = value.clone();
index += 2;
}
flag if flag.starts_with("--session=") => {
session_reference = flag[10..].to_string();
index += 1;
}
"--output" | "-o" => {
let value = args
.get(index + 1)
.ok_or_else(|| format!("missing value for {}", args[index]))?;
output_path = Some(PathBuf::from(value));
index += 2;
}
flag if flag.starts_with("--output=") => {
output_path = Some(PathBuf::from(&flag[9..]));
index += 1;
}
other if other.starts_with('-') => {
return Err(format!("unknown export option: {other}"));
}
other if output_path.is_none() => {
output_path = Some(PathBuf::from(other));
index += 1;
}
other => {
return Err(format!("unexpected export argument: {other}"));
}
}
}
Ok(CliAction::Export {
session_reference,
output_path,
output_format,
})
}
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
@@ -5257,6 +5321,172 @@ fn resolve_export_path(
Ok(cwd.join(final_name))
}
const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280;
fn summarize_tool_payload_for_markdown(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.split_whitespace().collect::<Vec<_>>().join(" "),
};
if compact.is_empty() {
return String::new();
}
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
}
fn run_export(
session_reference: &str,
output_path: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let handle = resolve_session_reference(session_reference)?;
let session = Session::load_from_path(&handle.path)?;
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown)?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
handle.id,
session.messages.len(),
);
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"message": report,
"session_id": handle.id,
"file": path.display().to_string(),
"messages": session.messages.len(),
}))?
),
}
return Ok(());
}
match output_format {
CliOutputFormat::Text => {
print!("{markdown}");
if !markdown.ends_with('\n') {
println!();
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"session_id": handle.id,
"file": handle.path.display().to_string(),
"messages": session.messages.len(),
"markdown": markdown,
}))?
),
}
Ok(())
}
fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String {
let mut lines = vec![
"# Conversation Export".to_string(),
String::new(),
format!("- **Session**: `{session_id}`"),
format!("- **File**: `{}`", session_path.display()),
format!("- **Messages**: {}", session.messages.len()),
];
if let Some(workspace_root) = session.workspace_root() {
lines.push(format!("- **Workspace**: `{}`", workspace_root.display()));
}
if let Some(fork) = &session.fork {
let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)");
lines.push(format!(
"- **Forked from**: `{}` (branch `{branch}`)",
fork.parent_session_id
));
}
if let Some(compaction) = &session.compaction {
lines.push(format!(
"- **Compactions**: {} (last removed {} messages)",
compaction.count, compaction.removed_message_count
));
}
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
for (index, message) in session.messages.iter().enumerate() {
let role = match message.role {
MessageRole::System => "System",
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
MessageRole::Tool => "Tool",
};
lines.push(format!("## {}. {role}", index + 1));
lines.push(String::new());
for block in &message.blocks {
match block {
ContentBlock::Text { text } => {
let trimmed = text.trim_end();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
lines.push(String::new());
}
}
ContentBlock::ToolUse { id, name, input } => {
lines.push(format!(
"**Tool call** `{name}` _(id `{}`)_",
short_tool_id(id)
));
let summary = summarize_tool_payload_for_markdown(input);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => {
let status = if *is_error { "error" } else { "ok" };
lines.push(format!(
"**Tool result** `{tool_name}` _(id `{}`, {status})_",
short_tool_id(tool_use_id)
));
let summary = summarize_tool_payload_for_markdown(output);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
}
}
if let Some(usage) = message.usage {
lines.push(format!(
"_tokens: in={} out={} cache_create={} cache_read={}_",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
));
lines.push(String::new());
}
}
lines.join("\n")
}
fn short_tool_id(id: &str) -> String {
let char_count = id.chars().count();
if char_count <= 12 {
return id.to_string();
}
let prefix: String = id.chars().take(12).collect();
format!("{prefix}")
}
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
Ok(load_system_prompt(
env::current_dir()?,
@@ -7068,6 +7298,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " claw login")?;
writeln!(out, " claw logout")?;
writeln!(out, " claw init")?;
writeln!(out, " claw export [PATH] [--session SESSION] [--output PATH]")?;
writeln!(
out,
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
)?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(
@@ -7147,6 +7382,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " claw doctor")?;
writeln!(out, " claw login")?;
writeln!(out, " claw init")?;
writeln!(out, " claw export")?;
writeln!(out, " claw export conversation.md")?;
Ok(())
}
@@ -7180,17 +7417,18 @@ mod tests {
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, format_user_visible_api_error,
normalize_permission_mode, parse_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, parse_history_count,
permission_policy, print_help_to, push_output_block, render_config_report,
render_diff_report, render_diff_report_for, render_memory_report,
render_prompt_history_report, render_repl_help, render_resume_usage, resolve_model_alias,
resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command,
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
normalize_permission_mode, parse_args, parse_export_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report,
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
render_session_markdown, resolve_model_alias, resolve_repl_model,
resolve_session_reference, response_to_events, resume_supported_slash_commands,
run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, status_context,
summarize_tool_payload_for_markdown, validate_no_args, write_mcp_server_fixture,
CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL,
SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -7982,6 +8220,277 @@ mod tests {
);
}
#[test]
fn parses_bare_export_subcommand_targeting_latest_session() {
// given
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let args = vec!["export".to_string()];
// when
let parsed = parse_args(&args).expect("bare export should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: None,
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_positional_output_path() {
// given
let args = vec!["export".to_string(), "conversation.md".to_string()];
// when
let parsed = parse_args(&args).expect("export with path should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: Some(PathBuf::from("conversation.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_session_and_output_flags() {
// given
let args = vec![
"export".to_string(),
"--session".to_string(),
"session-alpha".to_string(),
"--output".to_string(),
"/tmp/share.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("export flags should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: "session-alpha".to_string(),
output_path: Some(PathBuf::from("/tmp/share.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_inline_flag_values() {
// given
let args = vec![
"export".to_string(),
"--session=session-beta".to_string(),
"--output=/tmp/beta.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("export inline flags should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: "session-beta".to_string(),
output_path: Some(PathBuf::from("/tmp/beta.md")),
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn parses_export_subcommand_with_json_output_format() {
// given
let args = vec![
"--output-format=json".to_string(),
"export".to_string(),
"/tmp/notes.md".to_string(),
];
// when
let parsed = parse_args(&args).expect("json export should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: Some(PathBuf::from("/tmp/notes.md")),
output_format: CliOutputFormat::Json,
}
);
}
#[test]
fn rejects_unknown_export_options_with_helpful_message() {
// given
let args = vec!["export".to_string(), "--bogus".to_string()];
// when
let error = parse_args(&args).expect_err("unknown export option should fail");
// then
assert!(error.contains("unknown export option: --bogus"));
}
#[test]
fn rejects_export_with_extra_positional_after_path() {
// given
let args = vec![
"export".to_string(),
"first.md".to_string(),
"second.md".to_string(),
];
// when
let error = parse_args(&args).expect_err("multiple positionals should fail");
// then
assert!(error.contains("unexpected export argument: second.md"));
}
#[test]
fn parse_export_args_helper_defaults_to_latest_reference_and_no_output() {
// given
let args: Vec<String> = vec![];
// when
let parsed = parse_export_args(&args, CliOutputFormat::Text)
.expect("empty export args should parse");
// then
assert_eq!(
parsed,
CliAction::Export {
session_reference: LATEST_SESSION_REFERENCE.to_string(),
output_path: None,
output_format: CliOutputFormat::Text,
}
);
}
#[test]
fn render_session_markdown_includes_header_and_summarized_tool_calls() {
// given
let mut session = Session::new();
session.session_id = "session-export-test".to_string();
session.messages = vec![
ConversationMessage::user_text("How do I list files?"),
ConversationMessage::assistant(vec![
ContentBlock::Text {
text: "I'll run a tool.".to_string(),
},
ContentBlock::ToolUse {
id: "toolu_abcdefghijklmnop".to_string(),
name: "bash".to_string(),
input: r#"{"command":"ls -la"}"#.to_string(),
},
]),
ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "toolu_abcdefghijklmnop".to_string(),
tool_name: "bash".to_string(),
output: "total 8\ndrwxr-xr-x 2 user staff 64 Apr 7 12:00 .".to_string(),
is_error: false,
}],
usage: None,
},
];
// when
let markdown = render_session_markdown(
&session,
"session-export-test",
std::path::Path::new("/tmp/sessions/session-export-test.jsonl"),
);
// then
assert!(markdown.starts_with("# Conversation Export"));
assert!(markdown.contains("- **Session**: `session-export-test`"));
assert!(markdown.contains("- **Messages**: 3"));
assert!(markdown.contains("## 1. User"));
assert!(markdown.contains("How do I list files?"));
assert!(markdown.contains("## 2. Assistant"));
assert!(markdown.contains("**Tool call** `bash`"));
assert!(markdown.contains("toolu_abcdef…"));
assert!(markdown.contains("ls -la"));
assert!(markdown.contains("## 3. Tool"));
assert!(markdown.contains("**Tool result** `bash`"));
assert!(markdown.contains("ok"));
assert!(markdown.contains("total 8"));
}
#[test]
fn render_session_markdown_marks_tool_errors_and_skips_empty_summaries() {
// given
let mut session = Session::new();
session.session_id = "errs".to_string();
session.messages = vec![ConversationMessage {
role: MessageRole::Tool,
blocks: vec![ContentBlock::ToolResult {
tool_use_id: "short".to_string(),
tool_name: "read_file".to_string(),
output: " ".to_string(),
is_error: true,
}],
usage: None,
}];
// when
let markdown =
render_session_markdown(&session, "errs", std::path::Path::new("errs.jsonl"));
// then
assert!(markdown.contains("**Tool result** `read_file` _(id `short`, error)_"));
// an empty summary should not produce a stray blockquote line
assert!(!markdown.contains("> \n"));
}
#[test]
fn summarize_tool_payload_for_markdown_compacts_json_and_truncates_overflow() {
// given
let json_payload = r#"{
"command": "ls -la",
"cwd": "/tmp"
}"#;
let long_payload = "a".repeat(600);
// when
let compacted = summarize_tool_payload_for_markdown(json_payload);
let truncated = summarize_tool_payload_for_markdown(&long_payload);
// then
assert_eq!(compacted, r#"{"command":"ls -la","cwd":"/tmp"}"#);
assert!(truncated.ends_with('…'));
assert!(truncated.chars().count() <= 281);
}
#[test]
fn short_tool_id_truncates_long_identifiers_with_ellipsis() {
// given
let long = "toolu_01ABCDEFGHIJKLMN";
let short = "tool_1";
// when
let trimmed_long = short_tool_id(long);
let trimmed_short = short_tool_id(short);
// then
assert_eq!(trimmed_long, "toolu_01ABCD…");
assert_eq!(trimmed_short, "tool_1");
}
#[test]
fn parses_json_output_for_mcp_and_skills_commands() {
assert_eq!(