Compare commits

...
10 Commits
Author SHA1 Message Date
Bellman 747e160b59 Merge pull request #3374 from ultraworkers/release/v0.1.11
release: v0.1.11
2026-11-04 22:02:52 +09:00
knowhyandknowhy 6ac59ab90b docs: document LLMTR OpenAI-compatible gateway setup (#3373)
Co-authored-by: knowhy <194148385+knowhycodata@users.noreply.github.com>
2026-11-04 13:00:45 +00:00
clawdbot 67e5783894 release: v0.1.11 2026-11-04 12:59:09 +00:00
Iain db58b190bd fix(tools): stop NotebookEdit reusing cell ids after a delete (#3371)
Inserted cells were named cell-{len+1}, which is still taken once any
cell but the last has been deleted, so a later edit by the returned id
went to the older cell instead.
2026-11-04 12:02:30 +00:00
Bellman 51cfca114c Merge pull request #3370 from ultraworkers/release/v0.1.10
release: v0.1.10
2026-11-02 10:23:26 +09:00
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
21 changed files with 394 additions and 218 deletions

No files matched your search

+18
View File
@@ -5,6 +5,24 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.11] - 2026-11-04
### Fixed
- NotebookEdit no longer reuses an existing cell id for a newly inserted cell after a cell has been deleted, so later edits by id target the intended cell (#3371)
## [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 ## [0.1.8] - 2026-10-23
### Fixed ### Fixed
+21
View File
@@ -149,6 +149,27 @@ curl -sS "$OPENAI_BASE_URL/models" \
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway. For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## LLMTR
[LLMTR](https://llmtr.com) is an OpenAI-compatible gateway. Use the same client: set `OPENAI_BASE_URL` to `https://llmtr.com/v1` and `OPENAI_API_KEY` to a bearer key (`llmtr-...`).
```bash
export OPENAI_BASE_URL="https://llmtr.com/v1"
export OPENAI_API_KEY="llmtr-..."
claw --model "local/llmtr/qwen3-8-27b" prompt "Reply exactly HELLO_WORLD_123"
```
Send the catalog id unchanged. `llmtr/qwen3-8-27b` already starts with the gateway name, so that prefix stays on the wire. The `local/` hatch only selects the OpenAI-compatible transport and is removed before the request. A bare `qwen/` or `qwen-` name is a DashScope route and will not reach LLMTR, so vendor-prefixed ids need the same hatch: `local/<vendor>/<model>`.
Claw asks for up to 64,000 output tokens on these routes. LLMTR rejects a request whose `max_tokens` is above the model's output limit, so pick a model that allows at least that much. `llmtr/qwen3-8-27b` allows 262,144. Each model's limit is listed as `top_provider.max_completion_tokens` in the catalog below.
The public model list does not need a key:
```bash
curl -sS "https://llmtr.com/v1/models" \
| jq -r '.data[] | "\(.id)\t\(.top_provider.max_completion_tokens)"'
```
## Local skills install from disk ## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`. Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
+11 -11
View File
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api" name = "api"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"criterion", "criterion",
"reqwest", "reqwest",
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "claw-analog" name = "claw-analog"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"api", "api",
"clap", "clap",
@@ -498,7 +498,7 @@ dependencies = [
[[package]] [[package]]
name = "claw-rag-service" name = "claw-rag-service"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"axum 0.8.9", "axum 0.8.9",
"blake3", "blake3",
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "commands" name = "commands"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"plugins", "plugins",
"runtime", "runtime",
@@ -540,7 +540,7 @@ dependencies = [
[[package]] [[package]]
name = "compat-harness" name = "compat-harness"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"commands", "commands",
"runtime", "runtime",
@@ -1568,7 +1568,7 @@ dependencies = [
[[package]] [[package]]
name = "mock-anthropic-service" name = "mock-anthropic-service"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"api", "api",
"serde_json", "serde_json",
@@ -1761,7 +1761,7 @@ dependencies = [
[[package]] [[package]]
name = "plugins" name = "plugins"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -2136,7 +2136,7 @@ dependencies = [
[[package]] [[package]]
name = "runtime" name = "runtime"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"glob", "glob",
"plugins", "plugins",
@@ -2261,7 +2261,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "rusty-claude-cli" name = "rusty-claude-cli"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"api", "api",
"commands", "commands",
@@ -2622,7 +2622,7 @@ dependencies = [
[[package]] [[package]]
name = "telemetry" name = "telemetry"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -2902,7 +2902,7 @@ dependencies = [
[[package]] [[package]]
name = "tools" name = "tools"
version = "0.1.8" version = "0.1.11"
dependencies = [ dependencies = [
"api", "api",
"aspect-core", "aspect-core",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.8" version = "0.1.11"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
publish = false publish = false
+31 -8
View File
@@ -56,14 +56,14 @@ pub enum ApiError {
status: reqwest::StatusCode, status: reqwest::StatusCode,
error_type: Option<String>, error_type: Option<String>,
message: Option<String>, message: Option<String>,
request_id: Option<String>, request_id: Option<Box<str>>,
body: String, body: String,
retryable: bool, retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) /// 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. /// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay. /// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>, retry_after: Option<Box<Duration>>,
}, },
RetriesExhausted { RetriesExhausted {
attempts: u32, attempts: u32,
@@ -137,7 +137,7 @@ impl ApiError {
/// over the computed backoff delay when it exists. /// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> { pub fn retry_after(&self) -> Option<Duration> {
match self { 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(), Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None, _ => None,
} }
@@ -531,6 +531,29 @@ mod tests {
assert_eq!(snippet, "한글한글…"); 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] #[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() { fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api { 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." "Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(), .to_string(),
), ),
request_id: Some("req_jobdori_123".to_string()), request_id: Some("req_jobdori_123".into()),
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, 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." "Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(), .to_string(),
), ),
request_id: Some("req_nested_456".to_string()), request_id: Some("req_nested_456".into()),
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
@@ -586,7 +609,7 @@ mod tests {
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens." "This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(), .to_string(),
), ),
request_id: Some("req_ctx_123".to_string()), request_id: Some("req_ctx_123".into()),
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, 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." "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(), .to_string(),
), ),
request_id: Some("req_ctx_openai_123".to_string()), request_id: Some("req_ctx_openai_123".into()),
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, 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 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 body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); 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 { Err(ApiError::Api {
status, status,
@@ -1651,7 +1651,7 @@ mod tests {
status: reqwest::StatusCode::UNAUTHORIZED, status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()), error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".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(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
+12 -12
View File
@@ -261,7 +261,7 @@ impl OpenAiCompatClient {
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.map(str::to_owned), .map(str::to_owned),
message: Some(msg), message: Some(msg),
request_id, request_id: request_id.map(String::into_boxed_str),
body, body,
retryable: false, retryable: false,
suggested_action: suggested_action_for_status( suggested_action: suggested_action_for_status(
@@ -1653,7 +1653,7 @@ fn parse_sse_frame(
request_id: None, request_id: None,
body: trimmed.chars().take(200).collect(), body: trimmed.chars().take(200).collect(),
retryable: false, 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, retry_after: None,
}); });
} }
@@ -1706,7 +1706,7 @@ fn parse_sse_frame(
request_id: None, request_id: None,
body: payload.chars().take(200).collect(), body: payload.chars().take(200).collect(),
retryable: false, 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, retry_after: None,
}); });
} }
@@ -1760,11 +1760,11 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
} }
let headers = response.headers().clone(); 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 body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); 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); 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. /// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail. /// 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() { match status.as_u16() {
401 => Some("Check API key is set correctly and has not expired".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".to_string()), 403 => Some("Verify API key has required permissions for this operation"),
413 => Some("Reduce prompt size or context window before retrying".to_string()), 413 => Some("Reduce prompt size or context window before retrying"),
429 => Some("Wait a moment before retrying; consider reducing request rate".to_string()), 429 => Some("Wait a moment before retrying; consider reducing request rate"),
500 => Some("Provider server error - retry after a brief wait".to_string()), 500 => Some("Provider server error - retry after a brief wait"),
502..=504 => Some("Provider gateway error - retry after a brief wait".to_string()), 502..=504 => Some("Provider gateway error - retry after a brief wait"),
_ => None, _ => None,
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
); );
assert_eq!( assert_eq!(
request.headers.get("user-agent").map(String::as_str), request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.8") Some("claude-code/0.1.11")
); );
assert_eq!( assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str), 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; return None;
} }
let mut v = Vec::with_capacity(dim); let mut v = Vec::with_capacity(dim);
for chunk in blob.chunks_exact(4) { for chunk in blob.as_chunks::<4>().0 {
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); v.push(f32::from_le_bytes(*chunk));
} }
Some(v) Some(v)
} }
+1 -1
View File
@@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")] #[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len()); 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 dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?; let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?; insert_embedding(conn, cid, dim, &vec)?;
+2 -2
View File
@@ -7328,8 +7328,8 @@ mod tests {
.find(|r| r.origin == SkillOrigin::Bundled) .find(|r| r.origin == SkillOrigin::Bundled)
.expect("should have bundled skills root"); .expect("should have bundled skills root");
let loaded_skills = let loaded_skills = load_skills_from_roots(std::slice::from_ref(bundled_root))
load_skills_from_roots(&[bundled_root.clone()]).expect("should load bundled skills"); .expect("should load bundled skills");
assert!( assert!(
!loaded_skills.is_empty() || bundled_root.path.exists(), !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: {:?}", "message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i, i,
i - 1, 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 // Check for separator
if current.trim() == SEPARATOR { if current.trim() == SEPARATOR && separator_line.is_none() {
if separator_line.is_none() { separator_line = Some(j + 1);
separator_line = Some(j + 1); j += 1;
j += 1; continue;
continue;
}
} }
// Check for end marker // 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, /// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise. /// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) { if let Some(rest) = line.strip_prefix(prefix) {
let rest = &line[prefix.len()..];
// Accept either just the prefix or prefix + single space + label // Accept either just the prefix or prefix + single space + label
if rest.is_empty() { if rest.is_empty() {
Some("") Some("")
} else if rest.starts_with(' ') { } else if let Some(label) = rest.strip_prefix(' ') {
Some(rest[1..].trim()) Some(label.trim())
} else { } else {
None 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. /// Check if this range is valid.
pub fn is_valid(&self) -> bool { pub fn is_valid(&self) -> bool {
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line 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, 200).len(), 151);
assert_eq!(LineRange::new(50, 50).len(), 1); 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. /// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TridentStats { pub struct TridentStats {
pub superseded_count: usize, pub superseded_count: usize,
pub collapsed_chains: usize, pub collapsed_chains: usize,
@@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize, 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 { impl TridentStats {
pub fn format_report(&self) -> String { pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 { 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(); let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops { for ops in file_ops.values() {
if ops.len() < 2 { if ops.len() < 2 {
continue; continue;
} }
@@ -205,17 +190,55 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx { if let Some(last_write) = last_write_idx {
for op in ops { for op in ops {
if op.op_type == FileOp::Read && op.index < last_write { if 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
{
obsolete_indices.insert(op.index); 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 superseded_count = obsolete_indices.len();
let kept: Vec<ConversationMessage> = messages let kept: Vec<ConversationMessage> = messages
.iter() .iter()
@@ -239,18 +262,11 @@ fn extract_file_operation(block: &ContentBlock) -> Option<(String, FileOp)> {
}; };
Some((path, op_type)) Some((path, op_type))
} }
ContentBlock::ToolResult { // A tool result is the answer half of a tool use, not an independent
tool_name, output, .. // file operation. Counting it as one shifts `last_write` past the
} => { // write it belongs to, which makes the newest write look obsolete
let path = extract_path_from_tool_output(tool_name, output)?; // and splits its tool-use/tool-result pair.
let op_type = match tool_name.as_str() { ContentBlock::ToolResult { .. } => None,
"read_file" | "Read" => FileOp::Read,
"write_file" | "Write" => FileOp::Write,
"edit_file" | "Edit" => FileOp::Edit,
_ => return None,
};
Some((path, op_type))
}
ContentBlock::Text { .. } => None, ContentBlock::Text { .. } => None,
ContentBlock::Thinking { .. } => None, ContentBlock::Thinking { .. } => None,
} }
@@ -325,7 +341,7 @@ fn stage2_collapse(
usage: None, usage: None,
}); });
} else { } else {
result.extend(buffer.drain(..)); result.append(&mut buffer);
} }
buffer.clear(); buffer.clear();
result.push(msg.clone()); result.push(msg.clone());
@@ -476,7 +492,7 @@ fn stage3_cluster(
} }
let total_clustered: usize = cluster_assignments.len(); 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 result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::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 { mod tests {
use super::*; use super::*;
use crate::compact::CompactionConfig; use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, Session};
#[test] #[test]
fn stage1_removes_obsolete_file_reads() { fn stage1_removes_obsolete_file_reads() {
@@ -732,11 +748,95 @@ mod tests {
assert_eq!(kept.len(), messages.len()); 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] #[test]
fn stage2_collapses_chatty_messages() { fn stage2_collapses_chatty_messages() {
let mut messages = vec![]; let mut messages = vec![];
for i in 0..6 { 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 { messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"), text: format!("got {i}"),
}])); }]));
@@ -767,9 +867,9 @@ mod tests {
}, },
])); ]));
messages.push(ConversationMessage::tool_result( messages.push(ConversationMessage::tool_result(
&format!("read_{i}"), format!("read_{i}"),
"read_file", "read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false, 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. // Only reject for known top-level subcommands that don't use compact.
let first = rest[0].as_str(); let first = rest[0].as_str();
if is_known_top_level_subcommand(first) && first != "prompt" { if is_known_top_level_subcommand(first) && first != "prompt" {
return Err(format!( return Err("invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact".to_string());
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
));
} }
} }
@@ -3237,9 +3235,7 @@ fn parse_system_prompt_args(
})?; })?;
// #99: validate --date is a plausible date string (no newlines, reasonable length) // #99: validate --date is a plausible date string (no newlines, reasonable length)
if value.contains('\n') || value.contains('\r') { if value.contains('\n') || value.contains('\r') {
return Err(format!( return Err("invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".to_string());
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
));
} }
if value.len() > 20 { if value.len() > 20 {
return Err(format!( return Err(format!(
@@ -3476,11 +3472,7 @@ impl DiagnosticCheck {
fn json_value(&self) -> Value { fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704). // Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let mut value = Map::from_iter([ let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())), ("id".to_string(), Value::String(id.clone())),
( (
@@ -6974,16 +6966,15 @@ fn run_resume_command(
} }
SlashCommand::Plugins { action, target } => { SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload) // Only list is supported in resume mode (no runtime to reload)
match action.as_deref() { if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
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. // #777: use interactive_only: prefix + \n hint so #776's classify/split
// Orchestrators can now detect this and switch to a live REPL instead of retrying. // emits error_kind:interactive_only + non-null hint instead of unknown+null.
return Err(format!( // Orchestrators can now detect this and switch to a live REPL instead of retrying.
"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." return Err(format!(
).into()); "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 cwd = env::current_dir()?;
let payload = plugins_command_payload_for( let payload = plugins_command_payload_for(
@@ -8166,8 +8157,7 @@ impl LiveCli {
let max_compact_rounds = 4; let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0]; let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds { for (round, preserve) in preserve_schedule.into_iter().enumerate() {
let preserve = preserve_schedule[round];
println!( println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...", " Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1, round + 1,
@@ -8995,8 +8985,8 @@ impl LiveCli {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. // #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. // Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") { if action == Some("list") {
if let Some(filter) = target.as_deref() { if let Some(filter) = target {
if filter.starts_with('-') { if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) { if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error. // ROADMAP #817: this is a handled local inventory parse error.
@@ -10002,6 +9992,8 @@ fn print_status_snapshot(
Ok(()) Ok(())
} }
// Keep the independently optional provenance fields explicit at rendering callsites.
#[allow(clippy::too_many_arguments)]
fn status_json_value( fn status_json_value(
model: Option<&str>, model: Option<&str>,
usage: StatusUsage, 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; // (#731: "not supported on macOS" is a degraded state, not a hard error;
// filesystem_active:true means partial containment is working) // filesystem_active:true means partial containment is working)
// error = enabled but unsupported AND no filesystem sandbox either (nothing active) // error = enabled but unsupported AND no filesystem sandbox either (nothing active)
let top_status = if !status.enabled { let top_status = if !status.enabled || status.active {
"ok"
} else if status.active {
"ok" "ok"
} else if status.supported { } else if status.supported {
"warn" "warn"
@@ -10874,10 +10864,7 @@ fn render_doctor_help_json() -> serde_json::Value {
}) })
} }
/// #683-#692: extract structured metadata from help prose type HelpMetadata = (
fn extract_help_metadata(
topic: LocalHelpTopic,
) -> (
Option<String>, // usage Option<String>, // usage
Option<String>, // purpose Option<String>, // purpose
Option<String>, // output description Option<String>, // output description
@@ -10886,7 +10873,10 @@ fn extract_help_metadata(
Option<Vec<String>>, // aliases Option<Vec<String>>, // aliases
bool, // local_only bool, // local_only
bool, // requires_credentials 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 text = render_help_topic(topic);
let mut usage = None; let mut usage = None;
let mut purpose = None; let mut purpose = None;
@@ -11945,7 +11935,7 @@ fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) ->
"Prompt history".to_string(), "Prompt history".to_string(),
format!(" Total {total}"), format!(" Total {total}"),
format!(" Showing {} most recent", shown.len()), 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(), String::new(),
]; ];
for (offset, entry) in shown.iter().enumerate() { for (offset, entry) in shown.iter().enumerate() {
@@ -13204,6 +13194,8 @@ struct AnthropicRuntimeClient {
} }
impl AnthropicRuntimeClient { impl AnthropicRuntimeClient {
// These independent runtime dependencies are supplied together at construction.
#[allow(clippy::too_many_arguments)]
fn new( fn new(
session_id: &str, session_id: &str,
model: String, model: String,
@@ -14743,37 +14735,37 @@ fn convert_message(message: &ConversationMessage) -> Option<InputMessage> {
let content = message let content = message
.blocks .blocks
.iter() .iter()
.filter_map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } => Some(InputContentBlock::Text { text: text.clone() }), ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking { ContentBlock::Thinking {
thinking, thinking,
signature, signature,
} => { } => {
// 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段 // 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段
// 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误) // 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误)
Some(InputContentBlock::Thinking { InputContentBlock::Thinking {
thinking: thinking.clone(), thinking: thinking.clone(),
signature: signature.clone(), signature: signature.clone(),
}) }
} }
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(), id: id.clone(),
name: name.clone(), name: name.clone(),
input: serde_json::from_str(input) input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })), .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
}), },
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_use_id, tool_use_id,
output, output,
is_error, is_error,
.. ..
} => Some(InputContentBlock::ToolResult { } => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(), tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text { content: vec![ToolResultContentBlock::Text {
text: output.clone(), text: output.clone(),
}], }],
is_error: *is_error, is_error: *is_error,
}), },
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage { (!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." "Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(), .to_string(),
), ),
request_id: Some("req_jobdori_789".to_string()), request_id: Some("req_jobdori_789".into()),
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, 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." "Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(), .to_string(),
), ),
request_id: Some("req_jobdori_790".to_string()), request_id: Some("req_jobdori_790".into()),
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
@@ -15249,7 +15241,7 @@ mod tests {
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens." "This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(), .to_string(),
), ),
request_id: Some("req_ctx_456".to_string()), request_id: Some("req_ctx_456".into()),
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, 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." "Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(), .to_string(),
), ),
request_id: Some("req_ctx_openai_456".to_string()), request_id: Some("req_ctx_openai_456".into()),
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
@@ -15318,7 +15310,7 @@ mod tests {
status: "413".parse().expect("status"), status: "413".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()), error_type: Some("invalid_request_error".to_string()),
message: Some("Request is too large for this model's context window.".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(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
@@ -17787,7 +17779,7 @@ mod tests {
for action in ["remove", "uninstall", "delete"] { for action in ["remove", "uninstall", "delete"] {
assert_eq!( assert_eq!(
parse_args(&["skills".to_string(), action.to_string()]) parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")), .unwrap_or_else(|_| panic!("skills {action} should parse")),
CliAction::Skills { CliAction::Skills {
args: Some(action.to_string()), args: Some(action.to_string()),
output_format: CliOutputFormat::Text, 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 { pub struct MarkdownStreamState {
pending: String, pending: String,
/// Track whether the last emitted output ended with a newline. /// Track whether the last emitted output ended with a newline.
@@ -640,15 +640,6 @@ pub struct MarkdownStreamState {
last_output_ends_with_newline: bool, 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 { impl MarkdownStreamState {
#[must_use] #[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> { 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 // If the previous output didn't end with a newline and this chunk starts
// with a block-level element, ensure proper spacing before it. // with a block-level element, ensure proper spacing before it.
if !self.last_output_ends_with_newline && !rendered.is_empty() { if !self.last_output_ends_with_newline
if stripped.starts_with("\u{256d}\u{2500}") && !rendered.is_empty()
&& (stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```") || stripped.starts_with("```")
|| stripped.starts_with("|") || stripped.starts_with("|")
|| stripped.starts_with("• ") || stripped.starts_with("• ")
|| stripped.starts_with("1. ") || stripped.starts_with("1. ")
|| stripped.starts_with("-") || stripped.starts_with("-"))
{ {
rendered = format!("\n\n{}", rendered); rendered = format!("\n\n{}", rendered);
}
} }
// When markdown ends with a code block closing marker or other block-level element, // 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: /// Safe boundaries are:
/// - After a closing fence line (when fence is closed) /// - After a closing fence line (when fence is closed)
/// - After an empty line /// - After an empty line
///
/// This function returns the position of the last safe boundary found. /// This function returns the position of the last safe boundary found.
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> { fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None; 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 runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
use serde_json;
const PROVIDERS: &[(&str, &str, &str)] = &[ const PROVIDERS: &[(&str, &str, &str)] = &[
("1", "Anthropic", "anthropic"), ("1", "Anthropic", "anthropic"),
("2", "xAI / Grok", "xai"), ("2", "xAI / Grok", "xai"),
@@ -1016,13 +1016,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert!( assert!(
!plugins !plugins
.as_object() .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" "plugins list should not include reload_runtime"
); );
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("target")), .is_some_and(|o| o.contains_key("target")),
"plugins list should not include target" "plugins list should not include target"
); );
// #703: structured summary replaces prose message // #703: structured summary replaces prose message
@@ -1716,13 +1716,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert!( assert!(
!plugins !plugins
.as_object() .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" "plugins list should not include reload_runtime"
); );
assert!( assert!(
!plugins !plugins
.as_object() .as_object()
.map_or(false, |o| o.contains_key("target")), .is_some_and(|o| o.contains_key("target")),
"plugins list should not include target" "plugins list should not include target"
); );
assert!( 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}" "claw prompt empty arg must retain abort action (#823); got: {parsed}"
); );
assert!( 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)" "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}" "invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
); );
assert!( assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low") parsed["hint"]
|| h.contains("medium") .as_str()
|| h.contains("high")), .is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
"hint must mention valid values (#756): {parsed}" "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}" "missing --model value must be missing_flag_value (#756): {parsed2}"
); );
assert!( 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}" "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}" "flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
); );
assert!( 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)" "missing_prompt hint must be non-empty (#755)"
); );
} }
@@ -3407,7 +3407,7 @@ fn config_unsupported_section_json_hint_741() {
assert!( assert!(
parsed["supported_sections"] parsed["supported_sections"]
.as_array() .as_array()
.map_or(false, |a| !a.is_empty()), .is_some_and(|a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)" "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. // On success stdout has kind:export; on failure stderr has type:error.
// Either way, both envelopes must be valid JSON. // Either way, both envelopes must be valid JSON.
let stdout = String::from_utf8_lossy(&output.stdout); 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() { if output.status.success() {
let parsed: serde_json::Value = 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" "malformed settings.json should cause non-zero exit"
); );
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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" "claw {subcmd} should exit non-zero"
); );
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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" "claw --resume latest compact should exit non-zero"
); );
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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" "claw session {sub} should exit non-zero"
); );
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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", "claw {} should exit non-zero",
args.join(" ") args.join(" ")
); );
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = 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"], &[]); let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
assert!(!output.status.success(), "agents bogus should fail"); assert!(!output.status.success(), "agents bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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"], &[]); let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
assert!(!output.status.success(), "plugins bogus should fail"); assert!(!output.status.success(), "plugins bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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"], &[]); let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
assert!(!output.status.success(), "mcp bogus should fail"); 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 stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let json_str = if stdout.trim().starts_with('{') { 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", "claw {} should exit non-zero",
args.join(" ") args.join(" ")
); );
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout let json_line = stdout
@@ -4090,7 +4070,6 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() {
!output.status.success(), !output.status.success(),
"/plugins {mutation} in resume mode should exit non-zero" "/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 stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout let json_line = stdout
@@ -4147,7 +4126,6 @@ fn resume_skills_invocation_is_typed_interactive_only_779() {
!output.status.success(), !output.status.success(),
"/skills <skill> in resume mode should exit non-zero" "/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 stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = 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"], &[]); let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
assert!(!output.status.success(), "acp start should fail"); assert!(!output.status.success(), "acp start should fail");
let stdout = String::from_utf8_lossy(&output.stdout); 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 let json_line = stdout
.lines() .lines()
.find(|l| l.trim_start().starts_with('{')) .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 // Fresh init — already_initialized should be false, hint should mention CLAUDE.md
let output = run_claw(&root, &["--output-format", "json", "init"], &[]); let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output.status.success(), "init should succeed"); assert!(output.status.success(), "init should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let raw = if stdout.trim_start().starts_with('{') { 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 // Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]); let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output2.status.success(), "re-init should succeed"); 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 stderr2 = String::from_utf8_lossy(&output2.stderr);
let stdout2 = String::from_utf8_lossy(&output2.stdout); let stdout2 = String::from_utf8_lossy(&output2.stdout);
let raw2 = if stdout2.trim_start().starts_with('{') { 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"); 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 stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1 let j1: serde_json::Value = stdout1
.lines() .lines()
@@ -4403,7 +4376,6 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
&[], &[],
); );
assert!(!out2.status.success(), "extra positional should fail"); 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 stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2 let j2: serde_json::Value = stdout2
.lines() .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 // "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]); let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
assert!(!output.status.success(), "unknown subcommand should fail"); 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 stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -4488,7 +4459,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
&[], &[],
); );
assert!(!out1.status.success()); assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout); let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1 let j1: serde_json::Value = stdout1
.lines() .lines()
@@ -4520,7 +4490,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
&[], &[],
); );
assert!(!out2.status.success()); assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout); let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2 let j2: serde_json::Value = stdout2
.lines() .lines()
@@ -4570,7 +4539,6 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() {
!output.status.success(), !output.status.success(),
"resume with directory should fail" "resume with directory should fail"
); );
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -4620,7 +4588,6 @@ fn skills_show_not_found_emits_single_json_object_788() {
assert!(!output.status.success(), "skills show unknown should fail"); 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. // 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). // 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 stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); 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()); assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout); let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1 let j1: serde_json::Value = stdout1
.lines() .lines()
@@ -4809,7 +4775,6 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
&[], &[],
); );
assert!(!out2.status.success()); assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout); let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2 let j2: serde_json::Value = stdout2
.lines() .lines()
@@ -4847,7 +4812,6 @@ fn config_extra_args_have_non_null_hint_791() {
&[], &[],
); );
assert!(!out1.status.success()); assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout); let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1 let j1: serde_json::Value = stdout1
.lines() .lines()
@@ -4881,7 +4845,6 @@ fn config_extra_args_have_non_null_hint_791() {
&[], &[],
); );
assert!(!out2.status.success()); assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout); let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2 let j2: serde_json::Value = stdout2
.lines() .lines()
@@ -5067,7 +5030,6 @@ fn plugins_uninstall_not_found_has_hint_793() {
"plugins uninstall not-found must exit non-zero (#793)" "plugins uninstall not-found must exit non-zero (#793)"
); );
// Error envelope goes to stderr (propagated via ? to main error handler) // 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 stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -5112,7 +5074,6 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
!output.status.success(), !output.status.success(),
"plugins install not-found-path must exit non-zero (#794)" "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 stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -5376,7 +5337,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
.iter() .iter()
.any(|agent| { .any(|agent| {
agent["name"] == "my-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") == 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(), !output.status.success(),
"plugins show with extra arg must exit non-zero (#797)" "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 stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -5567,7 +5527,6 @@ fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
String::from_utf8_lossy(&output.stdout) String::from_utf8_lossy(&output.stdout)
); );
let stderr = String::from_utf8_lossy(&output.stderr); 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("[error-kind: cli_parse]"), "{stderr}");
assert!( assert!(
stderr.contains("unknown option for `claw plugins list`: --"), stderr.contains("unknown option for `claw plugins list`: --"),
@@ -5592,7 +5551,6 @@ fn empty_prompt_has_non_null_hint_798() {
!output.status.success(), !output.status.success(),
"empty prompt must exit non-zero (#798)" "empty prompt must exit non-zero (#798)"
); );
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout let j: serde_json::Value = stdout
.lines() .lines()
@@ -109,7 +109,7 @@ fn parallel_tool_calls_are_answered_in_one_following_message() {
} }
/// Writes the permission preset the mock harness runs with. /// 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( fs::write(
config_home.join("settings.json"), config_home.join("settings.json"),
json!({ "permissions": { "defaultMode": "read-only" } }).to_string(), json!({ "permissions": { "defaultMode": "read-only" } }).to_string(),
+68 -5
View File
@@ -2096,9 +2096,13 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
let status = response.status().as_u16(); let status = response.status().as_u16();
let body = response.text().unwrap_or_default(); let body = response.text().unwrap_or_default();
let truncated_body = if body.len() > 8192 { let truncated_body = if body.len() > 8192 {
let mut end = 8192;
while !body.is_char_boundary(end) {
end -= 1;
}
format!( format!(
"{}\n\n[response truncated — {} bytes total]", "{}\n\n[response truncated — {} bytes total]",
&body[..8192], &body[..end],
body.len() body.len()
) )
} else { } else {
@@ -5993,7 +5997,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
NotebookEditMode::Insert => { NotebookEditMode::Insert => {
let resolved_cell_type = resolved_cell_type let resolved_cell_type = resolved_cell_type
.ok_or_else(|| String::from("insert mode requires a cell type"))?; .ok_or_else(|| String::from("insert mode requires a cell type"))?;
let new_id = make_cell_id(cells.len()); let new_id = make_cell_id(cells);
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source); let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
let insert_at = target_index.map_or(cells.len(), |index| index + 1); let insert_at = target_index.map_or(cells.len(), |index| index + 1);
cells.insert(insert_at, new_cell); cells.insert(insert_at, new_cell);
@@ -7018,8 +7022,11 @@ fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
} }
} }
fn make_cell_id(index: usize) -> String { fn make_cell_id(cells: &[Value]) -> String {
format!("cell-{}", index + 1) (cells.len() + 1..)
.map(|n| format!("cell-{n}"))
.find(|id| !cells.iter().any(|cell| cell["id"] == id.as_str()))
.expect("there is always a free id")
} }
fn parse_skill_description(contents: &str) -> Option<String> { fn parse_skill_description(contents: &str) -> Option<String> {
@@ -9865,11 +9872,23 @@ mod tests {
assert!(deleted_output["cell_type"].is_null()); assert!(deleted_output["cell_type"].is_null());
assert_eq!(deleted_output["new_source"], ""); assert_eq!(deleted_output["new_source"], "");
let reinserted = execute_tool(
"NotebookEdit",
&json!({
"notebook_path": path.display().to_string(),
"new_source": "print(4)\n",
"edit_mode": "insert"
}),
)
.expect("NotebookEdit insert after delete should succeed");
let reinserted_output: serde_json::Value = serde_json::from_str(&reinserted).expect("json");
assert_eq!(reinserted_output["cell_id"], "cell-4");
let final_notebook: serde_json::Value = let final_notebook: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook")) serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
.expect("valid notebook json"); .expect("valid notebook json");
let cells = final_notebook["cells"].as_array().expect("cells array"); let cells = final_notebook["cells"].as_array().expect("cells array");
assert_eq!(cells.len(), 2); assert_eq!(cells.len(), 3);
assert_eq!(cells[0]["cell_type"], "markdown"); assert_eq!(cells[0]["cell_type"], "markdown");
assert!(cells[0].get("outputs").is_none()); assert!(cells[0].get("outputs").is_none());
assert_eq!(cells[1]["cell_type"], "code"); assert_eq!(cells[1]["cell_type"], "code");
@@ -11437,4 +11456,48 @@ printf 'pwsh:%s' "$1"
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(answered, vec!["toolu_multi_read", "toolu_multi_grep"]); 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)
}
} }