Compare commits

..
Author SHA1 Message Date
clawdbot 1cae56fd3f release: v0.1.10 2026-11-02 01:14:53 +00:00
Iainandclawdbot a289024b35 fix(tools): cut RemoteTrigger responses on a char boundary (#3369)
* fix(tools): cut RemoteTrigger responses on a char boundary

A response over 8192 bytes with a multi-byte character across byte 8192 panicked on the slice.

* test(tools): add multibyte char boundary truncation test for RemoteTrigger

---------

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-02 00:03:39 +00:00
Bellman f4fff38de1 Merge pull request #3368 from ultraworkers/release/v0.1.9
release: v0.1.9
2026-10-29 04:59:12 +09:00
clawdbot 3a802f7bf9 release: v0.1.9 2026-10-28 19:58:30 +00:00
Bougie 8523d59a84 fix(runtime): keep tool-use/tool-result pairs paired in Trident compaction (#3367)
Trident stage 1 dropped the assistant message that carried a file tool
use while keeping the tool result that answered it, so the compacted
session ended up with orphaned `tool_result` blocks. Providers reject
such requests with HTTP 400 ("tool_result blocks must be a response to
a preceding tool_use"), which breaks the auto-compact retry path that
runs Trident before resending a request.

Stage 1 also counted a tool result as an independent file operation,
which pushed `last_write` past the write the result belonged to and made
the newest write look obsolete.

Only tool uses count as file operations now, and a tool result is
dropped together with the tool use that produced it.
2026-10-28 19:01:54 +00:00
20 changed files with 348 additions and 214 deletions

No files matched your search

+12
View File
@@ -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
+11 -11
View File
@@ -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
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.8"
version = "0.1.10"
edition = "2021"
license = "MIT"
publish = false
+31 -8
View File
@@ -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,
+3 -3
View File
@@ -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,
+12 -12
View File
@@ -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,
}
}
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)?;
+2 -2
View File
@@ -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(),
+1 -1
View File
@@ -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
);
}
}
+7 -10
View File
@@ -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());
}
}
+140 -40
View File
@@ -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,
));
}
+40 -48
View File
@@ -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,
+8 -16
View File
@@ -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(),
+49 -1
View File
@@ -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)
}
}