mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-06 00:00:00 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
747e160b59 | ||
|
|
6ac59ab90b | ||
|
|
67e5783894 | ||
|
|
db58b190bd | ||
|
|
51cfca114c | ||
|
|
1cae56fd3f | ||
|
|
a289024b35 | ||
|
|
f4fff38de1 | ||
|
|
3a802f7bf9 | ||
|
|
8523d59a84 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|||||||
Generated
+11
-11
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user