mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-05 00:00:04 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cae56fd3f | ||
|
|
a289024b35 | ||
|
|
f4fff38de1 | ||
|
|
3a802f7bf9 | ||
|
|
8523d59a84 |
No files matched your search
@@ -5,6 +5,18 @@ 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
|
||||
|
||||
- Trident compaction no longer leaves orphaned tool_result blocks after superseding a file tool use, which made providers reject auto-compact retries with HTTP 400 (#3367)
|
||||
|
||||
## [0.1.8] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+11
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"plugins",
|
||||
@@ -2261,7 +2261,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2622,7 +2622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2902,7 +2902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.8"
|
||||
version = "0.1.10"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -56,14 +56,14 @@ pub enum ApiError {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
request_id: Option<Box<str>>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
suggested_action: Option<String>,
|
||||
suggested_action: Option<&'static str>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
retry_after: Option<Duration>,
|
||||
retry_after: Option<Box<Duration>>,
|
||||
},
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
@@ -137,7 +137,7 @@ impl ApiError {
|
||||
/// over the computed backoff delay when it exists.
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
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,
|
||||
|
||||
@@ -886,11 +886,11 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
}
|
||||
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&headers);
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&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,
|
||||
|
||||
@@ -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<reqwest::Response
|
||||
}
|
||||
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&headers);
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.8")
|
||||
Some("claude-code/0.1.10")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn flush_path_batch(
|
||||
#[cfg(feature = "qdrant-index")]
|
||||
let mut qdrant_points: Vec<ChunkPoint> = 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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,12 +368,10 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
|
||||
}
|
||||
|
||||
// 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<ConflictBlock> {
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationMessag
|
||||
|
||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
|
||||
for (_path, ops) in &file_ops {
|
||||
for ops in file_ops.values() {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
@@ -205,17 +190,55 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.op_type == FileOp::Read && op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
{
|
||||
if op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tool result is only meaningful together with the tool use that
|
||||
// produced it. Dropping the tool use while keeping its result leaves an
|
||||
// orphaned `tool_result` block, which providers reject with
|
||||
// "tool_result blocks must be a response to a preceding tool_use".
|
||||
let dropped_tool_use_ids: BTreeSet<&str> = obsolete_indices
|
||||
.iter()
|
||||
.filter_map(|index| messages.get(*index))
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (index, message) in messages.iter().enumerate() {
|
||||
if obsolete_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let answers_dropped_tool_use = message.blocks.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolResult { tool_use_id, .. }
|
||||
if dropped_tool_use_ids.contains(tool_use_id.as_str())
|
||||
)
|
||||
});
|
||||
if !answers_dropped_tool_use {
|
||||
continue;
|
||||
}
|
||||
// Keep a message that still carries a surviving tool use: dropping it
|
||||
// would orphan that tool use instead.
|
||||
let keeps_surviving_tool_use = message.blocks.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolUse { id, .. }
|
||||
if !dropped_tool_use_ids.contains(id.as_str())
|
||||
)
|
||||
});
|
||||
if !keeps_surviving_tool_use {
|
||||
obsolete_indices.insert(index);
|
||||
}
|
||||
}
|
||||
|
||||
let superseded_count = obsolete_indices.len();
|
||||
let kept: Vec<ConversationMessage> = messages
|
||||
.iter()
|
||||
@@ -239,18 +262,11 @@ fn extract_file_operation(block: &ContentBlock) -> Option<(String, FileOp)> {
|
||||
};
|
||||
Some((path, op_type))
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => {
|
||||
let path = extract_path_from_tool_output(tool_name, output)?;
|
||||
let op_type = match tool_name.as_str() {
|
||||
"read_file" | "Read" => FileOp::Read,
|
||||
"write_file" | "Write" => FileOp::Write,
|
||||
"edit_file" | "Edit" => FileOp::Edit,
|
||||
_ => return None,
|
||||
};
|
||||
Some((path, op_type))
|
||||
}
|
||||
// A tool result is the answer half of a tool use, not an independent
|
||||
// file operation. Counting it as one shifts `last_write` past the
|
||||
// write it belongs to, which makes the newest write look obsolete
|
||||
// and splits its tool-use/tool-result pair.
|
||||
ContentBlock::ToolResult { .. } => None,
|
||||
ContentBlock::Text { .. } => None,
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
}
|
||||
@@ -325,7 +341,7 @@ fn stage2_collapse(
|
||||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer.drain(..));
|
||||
result.append(&mut buffer);
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
@@ -476,7 +492,7 @@ fn stage3_cluster(
|
||||
}
|
||||
|
||||
let total_clustered: usize = cluster_assignments.len();
|
||||
let clusters_found = cluster_id as usize;
|
||||
let clusters_found = cluster_id;
|
||||
|
||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
@@ -677,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() {
|
||||
@@ -732,11 +748,95 @@ mod tests {
|
||||
assert_eq!(kept.len(), messages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage1_keeps_tool_use_and_result_paired() {
|
||||
// Realistic transcript: the model reads the same file twice and then
|
||||
// rewrites it. Stage 1 must drop the obsolete reads *and* their tool
|
||||
// results, but it must never leave a `tool_result` behind without the
|
||||
// `tool_use` that produced it: providers reject such requests with
|
||||
// "tool_result blocks must be a response to a preceding tool_use".
|
||||
let messages = vec![
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"1",
|
||||
"read_file",
|
||||
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
|
||||
false,
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "2".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"2",
|
||||
"read_file",
|
||||
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
|
||||
false,
|
||||
),
|
||||
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
|
||||
id: "3".to_string(),
|
||||
name: "write_file".to_string(),
|
||||
input: r#"{"path":"src/main.rs","content":"new"}"#.to_string(),
|
||||
}]),
|
||||
ConversationMessage::tool_result(
|
||||
"3",
|
||||
"write_file",
|
||||
r#"{"type":"update","filePath":"/ws/src/main.rs","content":"new"}"#,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let (kept, superseded) = stage1_supersede(&messages);
|
||||
assert!(superseded > 0, "obsolete reads should be superseded");
|
||||
|
||||
let tool_use_ids: BTreeSet<&str> = kept
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for message in &kept {
|
||||
for block in &message.blocks {
|
||||
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
|
||||
assert!(
|
||||
tool_use_ids.contains(tool_use_id.as_str()),
|
||||
"orphaned tool_result for tool_use {tool_use_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The most recent write survives, together with its tool result.
|
||||
let has_tool_use = |id: &str| {
|
||||
kept.iter().any(|message| {
|
||||
message.blocks.iter().any(
|
||||
|block| matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id),
|
||||
)
|
||||
})
|
||||
};
|
||||
let has_tool_result = |id: &str| {
|
||||
kept.iter().any(|message| {
|
||||
message.blocks.iter().any(
|
||||
|block| matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id),
|
||||
)
|
||||
})
|
||||
};
|
||||
assert!(has_tool_use("3"), "the newest write must survive");
|
||||
assert!(has_tool_result("3"), "its tool result must survive too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}"),
|
||||
}]));
|
||||
@@ -767,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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1950,9 +1950,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
// 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 \"<prompt>\" or echo \"<prompt>\" | claw --compact"
|
||||
));
|
||||
return Err("invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | 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 <YYYY-MM-DD>"
|
||||
));
|
||||
return Err("invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".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<String>, // usage
|
||||
Option<String>, // purpose
|
||||
Option<String>, // output description
|
||||
@@ -10886,7 +10873,10 @@ fn extract_help_metadata(
|
||||
Option<Vec<String>>, // 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<InputMessage> {
|
||||
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::<Vec<_>>();
|
||||
(!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,
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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 <skill> 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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -2096,9 +2096,13 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().unwrap_or_default();
|
||||
let truncated_body = if body.len() > 8192 {
|
||||
let mut end = 8192;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!(
|
||||
"{}\n\n[response truncated — {} bytes total]",
|
||||
&body[..8192],
|
||||
&body[..end],
|
||||
body.len()
|
||||
)
|
||||
} else {
|
||||
@@ -11437,4 +11441,48 @@ printf 'pwsh:%s' "$1"
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(answered, vec!["toolu_multi_read", "toolu_multi_grep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_trigger_truncates_multibyte_char_at_boundary() {
|
||||
// This test verifies that run_remote_trigger safely truncates a body
|
||||
// when a multibyte UTF-8 character spans the 8192-byte boundary.
|
||||
// Without the char_boundary check, &body[..8192] would panic.
|
||||
let server = TestServer::spawn(Arc::new(|_request| {
|
||||
// Create a body that's >8192 bytes with a 4-byte character (🦀) at byte 8192.
|
||||
// Strategy: fill with ASCII to reach byte 8189, then place the 4-byte crab emoji
|
||||
// so that byte 8192 falls in the middle of it.
|
||||
let mut body = String::new();
|
||||
// 8189 ASCII 'a' characters = 8189 bytes
|
||||
for _ in 0..8189 {
|
||||
body.push('a');
|
||||
}
|
||||
// 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('\u{1F980}');
|
||||
// Add more content to make it clear we truncated
|
||||
body.push_str("extra_content_after_boundary");
|
||||
|
||||
HttpResponse::text(200, "OK", &body)
|
||||
}));
|
||||
|
||||
let input = json!({
|
||||
"url": format!("http://{}/test", server.addr()),
|
||||
"method": "GET"
|
||||
});
|
||||
|
||||
// This should not panic on the current head (with char_boundary check).
|
||||
// On main (with &body[..8192]), this would panic because byte 8192
|
||||
// is in the middle of the 4-byte crab emoji.
|
||||
let result = execute_tool("RemoteTrigger", &input).expect("tool should succeed");
|
||||
let output: serde_json::Value =
|
||||
serde_json::from_str(&result).expect("result should be valid JSON");
|
||||
|
||||
// Verify the response was truncated
|
||||
let body = output["body"].as_str().expect("body should be string");
|
||||
assert!(body.contains("[response truncated"));
|
||||
// The body should not contain the full extra_content_after_boundary
|
||||
assert!(!body.contains("extra_content_after_boundary"));
|
||||
// The truncation should have left us with valid UTF-8 (no partial emoji)
|
||||
let _ = body.to_string(); // This validates it's valid Rust string (which is valid UTF-8)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user