diff --git a/CHANGELOG.md b/CHANGELOG.md index 09739fc2..b1b804cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.10] - 2026-11-02 + +### Fixed + +- RemoteTrigger no longer panics when a webhook response over 8 KiB has a multibyte character across the truncation point; it is now cut on a character boundary (#3369) + ## [0.1.9] - 2026-10-29 ### Fixed diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 003912d8..7743beb4 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "api" -version = "0.1.9" +version = "0.1.10" dependencies = [ "criterion", "reqwest", @@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claw-analog" -version = "0.1.9" +version = "0.1.10" dependencies = [ "api", "clap", @@ -498,7 +498,7 @@ dependencies = [ [[package]] name = "claw-rag-service" -version = "0.1.9" +version = "0.1.10" dependencies = [ "axum 0.8.9", "blake3", @@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "commands" -version = "0.1.9" +version = "0.1.10" dependencies = [ "plugins", "runtime", @@ -540,7 +540,7 @@ dependencies = [ [[package]] name = "compat-harness" -version = "0.1.9" +version = "0.1.10" dependencies = [ "commands", "runtime", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "mock-anthropic-service" -version = "0.1.9" +version = "0.1.10" dependencies = [ "api", "serde_json", @@ -1761,7 +1761,7 @@ dependencies = [ [[package]] name = "plugins" -version = "0.1.9" +version = "0.1.10" dependencies = [ "serde", "serde_json", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "runtime" -version = "0.1.9" +version = "0.1.10" dependencies = [ "glob", "plugins", @@ -2261,7 +2261,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-claude-cli" -version = "0.1.9" +version = "0.1.10" dependencies = [ "api", "commands", @@ -2622,7 +2622,7 @@ dependencies = [ [[package]] name = "telemetry" -version = "0.1.9" +version = "0.1.10" dependencies = [ "serde", "serde_json", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "tools" -version = "0.1.9" +version = "0.1.10" dependencies = [ "api", "aspect-core", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 20972a2b..870242e8 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.9" +version = "0.1.10" edition = "2021" license = "MIT" publish = false diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index e8ec73a4..1e35ceec 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -56,14 +56,14 @@ pub enum ApiError { status: reqwest::StatusCode, error_type: Option, message: Option, - request_id: Option, + request_id: Option>, body: String, retryable: bool, /// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) - suggested_action: Option, + suggested_action: Option<&'static str>, /// Parsed Retry-After header value (seconds) for 429 responses. /// When present, overrides the exponential backoff delay. - retry_after: Option, + retry_after: Option>, }, RetriesExhausted { attempts: u32, @@ -137,7 +137,7 @@ impl ApiError { /// over the computed backoff delay when it exists. pub fn retry_after(&self) -> Option { match self { - Self::Api { retry_after, .. } => *retry_after, + Self::Api { retry_after, .. } => retry_after.as_deref().copied(), Self::RetriesExhausted { last_error, .. } => last_error.retry_after(), _ => None, } @@ -531,6 +531,29 @@ mod tests { assert_eq!(snippet, "한글한글…"); } + #[test] + fn retry_after_survives_retry_exhaustion_wrapping() { + let delay = std::time::Duration::from_secs(42); + let error = ApiError::Api { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + error_type: None, + message: None, + request_id: Some("retry-trace".into()), + body: String::new(), + retryable: true, + suggested_action: Some("Wait before retrying"), + retry_after: Some(Box::new(delay)), + }; + assert_eq!(error.retry_after(), Some(delay)); + let wrapped = ApiError::RetriesExhausted { + attempts: 2, + last_error: Box::new(error), + }; + assert_eq!(wrapped.retry_after(), Some(delay)); + assert_eq!(wrapped.request_id(), Some("retry-trace")); + assert!(wrapped.is_retryable()); + } + #[test] fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() { let error = ApiError::Api { @@ -540,7 +563,7 @@ mod tests { "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." .to_string(), ), - request_id: Some("req_jobdori_123".to_string()), + request_id: Some("req_jobdori_123".into()), body: String::new(), retryable: true, suggested_action: None, @@ -564,7 +587,7 @@ mod tests { "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." .to_string(), ), - request_id: Some("req_nested_456".to_string()), + request_id: Some("req_nested_456".into()), body: String::new(), retryable: true, suggested_action: None, @@ -586,7 +609,7 @@ mod tests { "This model's maximum context length is 200000 tokens, but your request used 230000 tokens." .to_string(), ), - request_id: Some("req_ctx_123".to_string()), + request_id: Some("req_ctx_123".into()), body: String::new(), retryable: false, suggested_action: None, @@ -607,7 +630,7 @@ mod tests { "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages." .to_string(), ), - request_id: Some("req_ctx_openai_123".to_string()), + request_id: Some("req_ctx_openai_123".into()), body: String::new(), retryable: false, suggested_action: None, diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 430b3eff..ccfbdfc8 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -886,11 +886,11 @@ async fn expect_success(response: reqwest::Response) -> Result(&body).ok(); let retryable = is_retryable_status(status); - let retry_after = parse_retry_after(&headers, status); + let retry_after = parse_retry_after(&headers, status).map(Box::new); Err(ApiError::Api { status, @@ -1651,7 +1651,7 @@ mod tests { status: reqwest::StatusCode::UNAUTHORIZED, error_type: Some("authentication_error".to_string()), message: Some("Invalid bearer token".to_string()), - request_id: Some("req_varleg_001".to_string()), + request_id: Some("req_varleg_001".into()), body: String::new(), retryable: false, suggested_action: None, diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 9c240c6f..2c885e21 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -261,7 +261,7 @@ impl OpenAiCompatClient { .and_then(|t| t.as_str()) .map(str::to_owned), message: Some(msg), - request_id, + request_id: request_id.map(String::into_boxed_str), body, retryable: false, suggested_action: suggested_action_for_status( @@ -1653,7 +1653,7 @@ fn parse_sse_frame( request_id: None, body: trimmed.chars().take(200).collect(), retryable: false, - suggested_action: Some("verify the API endpoint URL is correct".to_string()), + suggested_action: Some("verify the API endpoint URL is correct"), retry_after: None, }); } @@ -1706,7 +1706,7 @@ fn parse_sse_frame( request_id: None, body: payload.chars().take(200).collect(), retryable: false, - suggested_action: Some("verify the API endpoint URL is correct".to_string()), + suggested_action: Some("verify the API endpoint URL is correct"), retry_after: None, }); } @@ -1760,11 +1760,11 @@ async fn expect_success(response: reqwest::Response) -> Result(&body).ok(); let retryable = is_retryable_status(status); - let retry_after = parse_retry_after(&headers, status); + let retry_after = parse_retry_after(&headers, status).map(Box::new); let suggested_action = suggested_action_for_status(status); @@ -1819,14 +1819,14 @@ fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool { /// Generate a suggested user action based on the HTTP status code and error context. /// This provides actionable guidance when API requests fail. -fn suggested_action_for_status(status: reqwest::StatusCode) -> Option { +fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<&'static str> { match status.as_u16() { - 401 => Some("Check API key is set correctly and has not expired".to_string()), - 403 => Some("Verify API key has required permissions for this operation".to_string()), - 413 => Some("Reduce prompt size or context window before retrying".to_string()), - 429 => Some("Wait a moment before retrying; consider reducing request rate".to_string()), - 500 => Some("Provider server error - retry after a brief wait".to_string()), - 502..=504 => Some("Provider gateway error - retry after a brief wait".to_string()), + 401 => Some("Check API key is set correctly and has not expired"), + 403 => Some("Verify API key has required permissions for this operation"), + 413 => Some("Reduce prompt size or context window before retrying"), + 429 => Some("Wait a moment before retrying; consider reducing request rate"), + 500 => Some("Provider server error - retry after a brief wait"), + 502..=504 => Some("Provider gateway error - retry after a brief wait"), _ => None, } } diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index 35e7a849..65558f17 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() { ); assert_eq!( request.headers.get("user-agent").map(String::as_str), - Some("claude-code/0.1.9") + Some("claude-code/0.1.10") ); assert_eq!( request.headers.get("anthropic-beta").map(String::as_str), diff --git a/rust/crates/claw-rag-service/src/db.rs b/rust/crates/claw-rag-service/src/db.rs index 8b73ec74..3b8a2274 100644 --- a/rust/crates/claw-rag-service/src/db.rs +++ b/rust/crates/claw-rag-service/src/db.rs @@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option> { return None; } let mut v = Vec::with_capacity(dim); - for chunk in blob.chunks_exact(4) { - v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + for chunk in blob.as_chunks::<4>().0 { + v.push(f32::from_le_bytes(*chunk)); } Some(v) } diff --git a/rust/crates/claw-rag-service/src/ingest.rs b/rust/crates/claw-rag-service/src/ingest.rs index eae43629..c7083d14 100644 --- a/rust/crates/claw-rag-service/src/ingest.rs +++ b/rust/crates/claw-rag-service/src/ingest.rs @@ -66,7 +66,7 @@ async fn flush_path_batch( #[cfg(feature = "qdrant-index")] let mut qdrant_points: Vec = Vec::with_capacity(batch.len()); - for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) { + for ((ord, t), vec) in batch.drain(..).zip(vecs) { let dim = vec.len(); let cid = insert_chunk(conn, path, ord, &t)?; insert_embedding(conn, cid, dim, &vec)?; diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 2ee70a0b..b1b9b929 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -7328,8 +7328,8 @@ mod tests { .find(|r| r.origin == SkillOrigin::Bundled) .expect("should have bundled skills root"); - let loaded_skills = - load_skills_from_roots(&[bundled_root.clone()]).expect("should load bundled skills"); + let loaded_skills = load_skills_from_roots(std::slice::from_ref(bundled_root)) + .expect("should load bundled skills"); assert!( !loaded_skills.is_empty() || bundled_root.path.exists(), diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 797d3a6b..5bb89dd2 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -826,7 +826,7 @@ mod tests { "message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}", i, i - 1, - &messages[i - 1].blocks + messages[i - 1].blocks ); } } diff --git a/rust/crates/runtime/src/file_ops.rs b/rust/crates/runtime/src/file_ops.rs index 1ea0d42a..220de06c 100644 --- a/rust/crates/runtime/src/file_ops.rs +++ b/rust/crates/runtime/src/file_ops.rs @@ -368,12 +368,10 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec { } // Check for separator - if current.trim() == SEPARATOR { - if separator_line.is_none() { - separator_line = Some(j + 1); - j += 1; - continue; - } + if current.trim() == SEPARATOR && separator_line.is_none() { + separator_line = Some(j + 1); + j += 1; + continue; } // Check for end marker @@ -419,13 +417,12 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec { /// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix, /// or None otherwise. fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { - if line.starts_with(prefix) { - let rest = &line[prefix.len()..]; + if let Some(rest) = line.strip_prefix(prefix) { // Accept either just the prefix or prefix + single space + label if rest.is_empty() { Some("") - } else if rest.starts_with(' ') { - Some(rest[1..].trim()) + } else if let Some(label) = rest.strip_prefix(' ') { + Some(label.trim()) } else { None } diff --git a/rust/crates/runtime/src/line_range_selector.rs b/rust/crates/runtime/src/line_range_selector.rs index 13affe15..f87ac5a7 100644 --- a/rust/crates/runtime/src/line_range_selector.rs +++ b/rust/crates/runtime/src/line_range_selector.rs @@ -58,6 +58,11 @@ impl LineRange { } } + /// Check if this range contains no lines. + pub fn is_empty(&self) -> bool { + self.start_line > self.end_line + } + /// Check if this range is valid. pub fn is_valid(&self) -> bool { self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line @@ -344,4 +349,13 @@ mod tests { assert_eq!(LineRange::new(50, 200).len(), 151); assert_eq!(LineRange::new(50, 50).len(), 1); } + + #[test] + fn test_line_range_is_empty() { + assert!(LineRange::new(51, 50).is_empty()); + assert_eq!(LineRange::new(51, 50).len(), 0); + assert!(!LineRange::new(50, 50).is_empty()); + assert!(!LineRange::new(50, 200).is_empty()); + assert!(!LineRange::new(0, 0).is_empty()); + } } diff --git a/rust/crates/runtime/src/trident.rs b/rust/crates/runtime/src/trident.rs index 8b60c9fc..da72f608 100644 --- a/rust/crates/runtime/src/trident.rs +++ b/rust/crates/runtime/src/trident.rs @@ -29,7 +29,7 @@ impl Default for TridentConfig { } /// Statistics from a Trident compaction run. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TridentStats { pub superseded_count: usize, pub collapsed_chains: usize, @@ -41,21 +41,6 @@ pub struct TridentStats { pub final_message_count: usize, } -impl Default for TridentStats { - fn default() -> Self { - Self { - superseded_count: 0, - collapsed_chains: 0, - messages_collapsed: 0, - clusters_found: 0, - messages_clustered: 0, - tokens_saved_estimate: 0, - original_message_count: 0, - final_message_count: 0, - } - } -} - impl TridentStats { pub fn format_report(&self) -> String { let compression = if self.final_message_count > 0 { @@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec = BTreeSet::new(); - for (_path, ops) in &file_ops { + for ops in file_ops.values() { if ops.len() < 2 { continue; } @@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec = Vec::new(); let mut cluster_buffers: BTreeMap> = BTreeMap::new(); @@ -712,7 +693,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String { mod tests { use super::*; use crate::compact::CompactionConfig; - use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; + use crate::session::{ContentBlock, ConversationMessage, Session}; #[test] fn stage1_removes_obsolete_file_reads() { @@ -855,7 +836,7 @@ mod tests { fn stage2_collapses_chatty_messages() { let mut messages = vec![]; for i in 0..6 { - messages.push(ConversationMessage::user_text(&format!("ok {i}"))); + messages.push(ConversationMessage::user_text(format!("ok {i}"))); messages.push(ConversationMessage::assistant(vec![ContentBlock::Text { text: format!("got {i}"), }])); @@ -886,9 +867,9 @@ mod tests { }, ])); messages.push(ConversationMessage::tool_result( - &format!("read_{i}"), + format!("read_{i}"), "read_file", - &format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), + format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), false, )); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 488b838d..273b7f33 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1950,9 +1950,7 @@ fn parse_args(args: &[String]) -> Result { // Only reject for known top-level subcommands that don't use compact. let first = rest[0].as_str(); if is_known_top_level_subcommand(first) && first != "prompt" { - return Err(format!( - "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact" - )); + return Err("invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact".to_string()); } } @@ -3237,9 +3235,7 @@ fn parse_system_prompt_args( })?; // #99: validate --date is a plausible date string (no newlines, reasonable length) if value.contains('\n') || value.contains('\r') { - return Err(format!( - "invalid_flag_value: --date value contains invalid characters.\nUsage: --date " - )); + return Err("invalid_flag_value: --date value contains invalid characters.\nUsage: --date ".to_string()); } if value.len() > 20 { return Err(format!( @@ -3476,11 +3472,7 @@ impl DiagnosticCheck { fn json_value(&self) -> Value { // Derive a stable snake_case id from the check name for machine-readable keying (#704). - let id = self - .name - .to_ascii_lowercase() - .replace(' ', "_") - .replace('-', "_"); + let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_"); let mut value = Map::from_iter([ ("id".to_string(), Value::String(id.clone())), ( @@ -6974,16 +6966,15 @@ fn run_resume_command( } SlashCommand::Plugins { action, target } => { // Only list is supported in resume mode (no runtime to reload) - match action.as_deref() { - Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => { - // #777: use interactive_only: prefix + \n hint so #776's classify/split - // emits error_kind:interactive_only + non-null hint instead of unknown+null. - // Orchestrators can now detect this and switch to a live REPL instead of retrying. - return Err(format!( - "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." - ).into()); - } - _ => {} + if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) = + action.as_deref() + { + // #777: use interactive_only: prefix + \n hint so #776's classify/split + // emits error_kind:interactive_only + non-null hint instead of unknown+null. + // Orchestrators can now detect this and switch to a live REPL instead of retrying. + return Err(format!( + "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." + ).into()); } let cwd = env::current_dir()?; let payload = plugins_command_payload_for( @@ -8166,8 +8157,7 @@ impl LiveCli { let max_compact_rounds = 4; let preserve_schedule = [4, 2, 1, 0]; - for round in 0..max_compact_rounds { - let preserve = preserve_schedule[round]; + for (round, preserve) in preserve_schedule.into_iter().enumerate() { println!( " Auto-compacting session (round {}/{}, preserving {} recent messages)...", round + 1, @@ -8995,8 +8985,8 @@ impl LiveCli { let cwd = env::current_dir()?; // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. // Previously the guard was JSON-only (#793); text mode silently returned empty success. - if action.as_deref() == Some("list") { - if let Some(filter) = target.as_deref() { + if action == Some("list") { + if let Some(filter) = target { if filter.starts_with('-') { if matches!(output_format, CliOutputFormat::Json) { // ROADMAP #817: this is a handled local inventory parse error. @@ -10002,6 +9992,8 @@ fn print_status_snapshot( Ok(()) } +// Keep the independently optional provenance fields explicit at rendering callsites. +#[allow(clippy::too_many_arguments)] fn status_json_value( model: Option<&str>, usage: StatusUsage, @@ -10498,9 +10490,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { // (#731: "not supported on macOS" is a degraded state, not a hard error; // filesystem_active:true means partial containment is working) // error = enabled but unsupported AND no filesystem sandbox either (nothing active) - let top_status = if !status.enabled { - "ok" - } else if status.active { + let top_status = if !status.enabled || status.active { "ok" } else if status.supported { "warn" @@ -10874,10 +10864,7 @@ fn render_doctor_help_json() -> serde_json::Value { }) } -/// #683-#692: extract structured metadata from help prose -fn extract_help_metadata( - topic: LocalHelpTopic, -) -> ( +type HelpMetadata = ( Option, // usage Option, // purpose Option, // output description @@ -10886,7 +10873,10 @@ fn extract_help_metadata( Option>, // aliases bool, // local_only bool, // requires_credentials -) { +); + +/// #683-#692: extract structured metadata from help prose +fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata { let text = render_help_topic(topic); let mut usage = None; let mut purpose = None; @@ -11945,7 +11935,7 @@ fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> "Prompt history".to_string(), format!(" Total {total}"), format!(" Showing {} most recent", shown.len()), - format!(" Reverse search Ctrl-R in the REPL"), + " Reverse search Ctrl-R in the REPL".to_string(), String::new(), ]; for (offset, entry) in shown.iter().enumerate() { @@ -13204,6 +13194,8 @@ struct AnthropicRuntimeClient { } impl AnthropicRuntimeClient { + // These independent runtime dependencies are supplied together at construction. + #[allow(clippy::too_many_arguments)] fn new( session_id: &str, model: String, @@ -14743,37 +14735,37 @@ fn convert_message(message: &ConversationMessage) -> Option { let content = message .blocks .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(InputContentBlock::Text { text: text.clone() }), + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, ContentBlock::Thinking { thinking, signature, } => { // 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段 // 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误) - Some(InputContentBlock::Thinking { + InputContentBlock::Thinking { thinking: thinking.clone(), signature: signature.clone(), - }) + } } - ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }), + }, ContentBlock::ToolResult { tool_use_id, output, is_error, .. - } => Some(InputContentBlock::ToolResult { + } => InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, - }), + }, }) .collect::>(); (!content.is_empty()).then(|| InputMessage { @@ -15160,7 +15152,7 @@ mod tests { "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." .to_string(), ), - request_id: Some("req_jobdori_789".to_string()), + request_id: Some("req_jobdori_789".into()), body: String::new(), retryable: true, suggested_action: None, @@ -15184,7 +15176,7 @@ mod tests { "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." .to_string(), ), - request_id: Some("req_jobdori_790".to_string()), + request_id: Some("req_jobdori_790".into()), body: String::new(), retryable: true, suggested_action: None, @@ -15249,7 +15241,7 @@ mod tests { "This model's maximum context length is 200000 tokens, but your request used 230000 tokens." .to_string(), ), - request_id: Some("req_ctx_456".to_string()), + request_id: Some("req_ctx_456".into()), body: String::new(), retryable: false, suggested_action: None, @@ -15283,7 +15275,7 @@ mod tests { "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages." .to_string(), ), - request_id: Some("req_ctx_openai_456".to_string()), + request_id: Some("req_ctx_openai_456".into()), body: String::new(), retryable: false, suggested_action: None, @@ -15318,7 +15310,7 @@ mod tests { status: "413".parse().expect("status"), error_type: Some("invalid_request_error".to_string()), message: Some("Request is too large for this model's context window.".to_string()), - request_id: Some("req_ctx_retry_789".to_string()), + request_id: Some("req_ctx_retry_789".into()), body: String::new(), retryable: false, suggested_action: None, @@ -17787,7 +17779,7 @@ mod tests { for action in ["remove", "uninstall", "delete"] { assert_eq!( parse_args(&["skills".to_string(), action.to_string()]) - .expect(&format!("skills {action} should parse")), + .unwrap_or_else(|_| panic!("skills {action} should parse")), CliAction::Skills { args: Some(action.to_string()), output_format: CliOutputFormat::Text, diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index bf830065..68d2bc95 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -632,7 +632,7 @@ impl TerminalRenderer { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct MarkdownStreamState { pending: String, /// Track whether the last emitted output ended with a newline. @@ -640,15 +640,6 @@ pub struct MarkdownStreamState { last_output_ends_with_newline: bool, } -impl Default for MarkdownStreamState { - fn default() -> Self { - Self { - pending: String::new(), - last_output_ends_with_newline: false, - } - } -} - impl MarkdownStreamState { #[must_use] pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option { @@ -662,16 +653,16 @@ impl MarkdownStreamState { // If the previous output didn't end with a newline and this chunk starts // with a block-level element, ensure proper spacing before it. - if !self.last_output_ends_with_newline && !rendered.is_empty() { - if stripped.starts_with("\u{256d}\u{2500}") + if !self.last_output_ends_with_newline + && !rendered.is_empty() + && (stripped.starts_with("\u{256d}\u{2500}") || stripped.starts_with("```") || stripped.starts_with("|") || stripped.starts_with("• ") || stripped.starts_with("1. ") - || stripped.starts_with("-") - { - rendered = format!("\n\n{}", rendered); - } + || stripped.starts_with("-")) + { + rendered = format!("\n\n{}", rendered); } // When markdown ends with a code block closing marker or other block-level element, @@ -916,6 +907,7 @@ fn normalize_nested_fences(markdown: &str) -> String { /// Safe boundaries are: /// - After a closing fence line (when fence is closed) /// - After an empty line +/// /// This function returns the position of the last safe boundary found. fn find_stream_safe_boundary(markdown: &str) -> Option { let mut open_fence: Option = None; diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index f61db1f4..fe8e7df4 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write}; use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig}; -use serde_json; - const PROVIDERS: &[(&str, &str, &str)] = &[ ("1", "Anthropic", "anthropic"), ("2", "xAI / Grok", "xai"), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 401d7617..a6c818db 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -1016,13 +1016,13 @@ fn inventory_commands_emit_structured_json_when_requested() { assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("reload_runtime")), + .is_some_and(|o| o.contains_key("reload_runtime")), "plugins list should not include reload_runtime" ); assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("target")), + .is_some_and(|o| o.contains_key("target")), "plugins list should not include target" ); // #703: structured summary replaces prose message @@ -1716,13 +1716,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() { assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("reload_runtime")), + .is_some_and(|o| o.contains_key("reload_runtime")), "plugins list should not include reload_runtime" ); assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("target")), + .is_some_and(|o| o.contains_key("target")), "plugins list should not include target" ); assert!( @@ -2955,7 +2955,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() { "claw prompt empty arg must retain abort action (#823); got: {parsed}" ); assert!( - parsed["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed["hint"].as_str().is_some_and(|h| !h.is_empty()), "claw prompt empty arg missing_prompt hint must be non-empty (#823)" ); } @@ -2993,9 +2993,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() { "invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}" ); assert!( - parsed["hint"].as_str().map_or(false, |h| h.contains("low") - || h.contains("medium") - || h.contains("high")), + parsed["hint"] + .as_str() + .is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")), "hint must mention valid values (#756): {parsed}" ); @@ -3021,7 +3021,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() { "missing --model value must be missing_flag_value (#756): {parsed2}" ); assert!( - parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()), "missing --model hint must be non-empty (#756): {parsed2}" ); } @@ -3265,7 +3265,7 @@ fn short_p_flag_swallows_no_flags_755() { "flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}" ); assert!( - parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()), "missing_prompt hint must be non-empty (#755)" ); } @@ -3407,7 +3407,7 @@ fn config_unsupported_section_json_hint_741() { assert!( parsed["supported_sections"] .as_array() - .map_or(false, |a| !a.is_empty()), + .is_some_and(|a| !a.is_empty()), "config {section} JSON must include supported_sections (#741)" ); } @@ -3464,11 +3464,6 @@ fn export_json_has_kind_702() { // On success stdout has kind:export; on failure stderr has type:error. // Either way, both envelopes must be valid JSON. let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr) - .lines() - .filter(|l| l.starts_with('{')) - .collect::>() - .join(""); if output.status.success() { let parsed: serde_json::Value = @@ -3557,8 +3552,6 @@ fn config_parse_error_has_typed_error_kind_and_hint_764() { "malformed settings.json should cause non-zero exit" ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3592,8 +3585,6 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() { "claw {subcmd} should exit non-zero" ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3732,8 +3723,6 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() { "claw --resume latest compact should exit non-zero" ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3772,8 +3761,6 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767( "claw session {sub} should exit non-zero" ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3825,7 +3812,6 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() { "claw {} should exit non-zero", args.join(" ") ); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout @@ -3868,8 +3854,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() { let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]); assert!(!output.status.success(), "agents bogus should fail"); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3892,8 +3876,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() { let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]); assert!(!output.status.success(), "plugins bogus should fail"); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -3911,7 +3893,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() { { let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]); assert!(!output.status.success(), "mcp bogus should fail"); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let json_str = if stdout.trim().starts_with('{') { @@ -4026,7 +4007,6 @@ fn interactive_only_guard_batch_769_to_771() { "claw {} should exit non-zero", args.join(" ") ); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout @@ -4090,7 +4070,6 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() { !output.status.success(), "/plugins {mutation} in resume mode should exit non-zero" ); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout @@ -4147,7 +4126,6 @@ fn resume_skills_invocation_is_typed_interactive_only_779() { !output.status.success(), "/skills in resume mode should exit non-zero" ); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout @@ -4189,8 +4167,6 @@ fn acp_unsupported_invocation_has_hint_782() { let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]); assert!(!output.status.success(), "acp start should fail"); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout .lines() .find(|l| l.trim_start().starts_with('{')) @@ -4226,7 +4202,6 @@ fn init_json_envelope_has_hint_and_already_initialized_783() { // Fresh init — already_initialized should be false, hint should mention CLAUDE.md let output = run_claw(&root, &["--output-format", "json", "init"], &[]); assert!(output.status.success(), "init should succeed"); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let raw = if stdout.trim_start().starts_with('{') { @@ -4295,7 +4270,6 @@ fn init_json_envelope_has_hint_and_already_initialized_783() { // Idempotent re-init — already_initialized should be true let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]); assert!(output2.status.success(), "re-init should succeed"); - let stdout2 = String::from_utf8_lossy(&output2.stdout); let stderr2 = String::from_utf8_lossy(&output2.stderr); let stdout2 = String::from_utf8_lossy(&output2.stdout); let raw2 = if stdout2.trim_start().starts_with('{') { @@ -4376,7 +4350,6 @@ fn export_arg_errors_have_typed_kind_and_hint_784() { &[], ); assert!(!out1.status.success(), "--output with no value should fail"); - let stderr1 = String::from_utf8_lossy(&out1.stderr); let stdout1 = String::from_utf8_lossy(&out1.stdout); let j1: serde_json::Value = stdout1 .lines() @@ -4403,7 +4376,6 @@ fn export_arg_errors_have_typed_kind_and_hint_784() { &[], ); assert!(!out2.status.success(), "extra positional should fail"); - let stderr2 = String::from_utf8_lossy(&out2.stderr); let stdout2 = String::from_utf8_lossy(&out2.stdout); let j2: serde_json::Value = stdout2 .lines() @@ -4440,7 +4412,6 @@ fn unknown_subcommand_returns_typed_kind_785() { // "dump" is close enough to "dump-manifests" to trigger the typo suggestion path let output = run_claw(&root, &["--output-format", "json", "dump"], &[]); assert!(!output.status.success(), "unknown subcommand should fail"); - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() @@ -4488,7 +4459,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() { &[], ); assert!(!out1.status.success()); - let stderr1 = String::from_utf8_lossy(&out1.stderr); let stdout1 = String::from_utf8_lossy(&out1.stdout); let j1: serde_json::Value = stdout1 .lines() @@ -4520,7 +4490,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() { &[], ); assert!(!out2.status.success()); - let stderr2 = String::from_utf8_lossy(&out2.stderr); let stdout2 = String::from_utf8_lossy(&out2.stdout); let j2: serde_json::Value = stdout2 .lines() @@ -4570,7 +4539,6 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() { !output.status.success(), "resume with directory should fail" ); - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() @@ -4620,7 +4588,6 @@ fn skills_show_not_found_emits_single_json_object_788() { assert!(!output.status.success(), "skills show unknown should fail"); // Skills handler emits JSON to stdout; the duplicate was on stderr from the main error path. // After fix: stdout has 1 JSON object, stderr has none (no duplicate). - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); @@ -4782,7 +4749,6 @@ fn system_prompt_unknown_option_returns_typed_kind_790() { &[], ); assert!(!out1.status.success()); - let stderr1 = String::from_utf8_lossy(&out1.stderr); let stdout1 = String::from_utf8_lossy(&out1.stdout); let j1: serde_json::Value = stdout1 .lines() @@ -4809,7 +4775,6 @@ fn system_prompt_unknown_option_returns_typed_kind_790() { &[], ); assert!(!out2.status.success()); - let stderr2 = String::from_utf8_lossy(&out2.stderr); let stdout2 = String::from_utf8_lossy(&out2.stdout); let j2: serde_json::Value = stdout2 .lines() @@ -4847,7 +4812,6 @@ fn config_extra_args_have_non_null_hint_791() { &[], ); assert!(!out1.status.success()); - let stderr1 = String::from_utf8_lossy(&out1.stderr); let stdout1 = String::from_utf8_lossy(&out1.stdout); let j1: serde_json::Value = stdout1 .lines() @@ -4881,7 +4845,6 @@ fn config_extra_args_have_non_null_hint_791() { &[], ); assert!(!out2.status.success()); - let stderr2 = String::from_utf8_lossy(&out2.stderr); let stdout2 = String::from_utf8_lossy(&out2.stdout); let j2: serde_json::Value = stdout2 .lines() @@ -5067,7 +5030,6 @@ fn plugins_uninstall_not_found_has_hint_793() { "plugins uninstall not-found must exit non-zero (#793)" ); // Error envelope goes to stderr (propagated via ? to main error handler) - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() @@ -5112,7 +5074,6 @@ fn plugins_install_not_found_path_returns_typed_kind_794() { !output.status.success(), "plugins install not-found-path must exit non-zero (#794)" ); - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() @@ -5376,7 +5337,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() { .iter() .any(|agent| { agent["name"] == "my-agent" - && PathBuf::from(agent["path"].as_str().expect("listed agent path")) + && *agent["path"].as_str().expect("listed agent path") == fs::canonicalize(&agent_path).expect("canonical listed agent path") })); } @@ -5501,7 +5462,6 @@ fn plugins_extra_args_have_non_null_hint_797() { !output.status.success(), "plugins show with extra arg must exit non-zero (#797)" ); - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() @@ -5567,7 +5527,6 @@ fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() { String::from_utf8_lossy(&output.stdout) ); let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}"); assert!( stderr.contains("unknown option for `claw plugins list`: --"), @@ -5592,7 +5551,6 @@ fn empty_prompt_has_non_null_hint_798() { !output.status.success(), "empty prompt must exit non-zero (#798)" ); - let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let j: serde_json::Value = stdout .lines() diff --git a/rust/crates/rusty-claude-cli/tests/parallel_tool_result_pairing.rs b/rust/crates/rusty-claude-cli/tests/parallel_tool_result_pairing.rs index da450f4f..b4dfa42f 100644 --- a/rust/crates/rusty-claude-cli/tests/parallel_tool_result_pairing.rs +++ b/rust/crates/rusty-claude-cli/tests/parallel_tool_result_pairing.rs @@ -109,7 +109,7 @@ fn parallel_tool_calls_are_answered_in_one_following_message() { } /// Writes the permission preset the mock harness runs with. -fn prepare_config_home(config_home: &PathBuf) { +fn prepare_config_home(config_home: &std::path::Path) { fs::write( config_home.join("settings.json"), json!({ "permissions": { "defaultMode": "read-only" } }).to_string(), diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index bc77dd7f..24b61854 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -11458,7 +11458,7 @@ printf 'pwsh:%s' "$1" } // Crab emoji (🦀) is 4 bytes in UTF-8: F0 9F A6 80 // Placing it now puts byte 8192 inside it (byte 8189+3=8192 is the 4th byte of the emoji) - body.push_str("🦀"); + body.push('\u{1F980}'); // Add more content to make it clear we truncated body.push_str("extra_content_after_boundary");