mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-30 00:00:22 +08:00
Compare commits
7
Commits
release/v0.1.7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fff38de1 | ||
|
|
3a802f7bf9 | ||
|
|
8523d59a84 | ||
|
|
8aab1d4e54 | ||
|
|
26611f44c5 | ||
|
|
fd8527f899 | ||
|
|
22fe36072a |
No files matched your search
@@ -5,6 +5,19 @@ 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.9] - 2026-10-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Trident compaction no longer leaves orphaned tool_result blocks after superseding a file tool use, which made providers reject auto-compact retries with HTTP 400 (#3367)
|
||||||
|
|
||||||
|
## [0.1.8] - 2026-10-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web search error handling: explicit 'Web search unavailable (HTTP N)' error on non-2xx responses instead of junk results (#3365)
|
||||||
|
- Web search result filtering: drop links back to search engine host (#3365)
|
||||||
|
|
||||||
## [0.1.7] - 2026-10-19
|
## [0.1.7] - 2026-10-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+11
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claw-analog"
|
name = "claw-analog"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -498,7 +498,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claw-rag-service"
|
name = "claw-rag-service"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
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.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"plugins",
|
"plugins",
|
||||||
"runtime",
|
"runtime",
|
||||||
@@ -540,7 +540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compat-harness"
|
name = "compat-harness"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"commands",
|
"commands",
|
||||||
"runtime",
|
"runtime",
|
||||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mock-anthropic-service"
|
name = "mock-anthropic-service"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plugins"
|
name = "plugins"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "runtime"
|
name = "runtime"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
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.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"commands",
|
"commands",
|
||||||
@@ -2622,7 +2622,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telemetry"
|
name = "telemetry"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2902,7 +2902,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.6"
|
version = "0.1.9"
|
||||||
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.7"
|
version = "0.1.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -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.7")
|
Some("claude-code/0.1.9")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.headers.get("anthropic-beta").map(String::as_str),
|
request.headers.get("anthropic-beta").map(String::as_str),
|
||||||
|
|||||||
@@ -216,6 +216,48 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 +281,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,
|
||||||
}
|
}
|
||||||
@@ -732,6 +767,90 @@ 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![];
|
||||||
|
|||||||
@@ -3528,16 +3528,26 @@ fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String>
|
|||||||
let client = build_http_client()?;
|
let client = build_http_client()?;
|
||||||
let search_url = build_search_url(&input.query)?;
|
let search_url = build_search_url(&input.query)?;
|
||||||
let response = client
|
let response = client
|
||||||
.get(search_url)
|
.get(search_url.as_str())
|
||||||
.send()
|
.send()
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
// DuckDuckGo answers its bot challenge with HTTP 202 and a page without
|
||||||
|
// results, so anything but 200 is an unusable results page.
|
||||||
|
let status = response.status();
|
||||||
|
if status != reqwest::StatusCode::OK {
|
||||||
|
return Err(format!(
|
||||||
|
"Web search unavailable (HTTP {}) - the search provider is blocked or unreachable",
|
||||||
|
status.as_u16()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let final_url = response.url().clone();
|
let final_url = response.url().clone();
|
||||||
let html = response.text().map_err(|error| error.to_string())?;
|
let html = response.text().map_err(|error| error.to_string())?;
|
||||||
let mut hits = extract_search_hits(&html);
|
let mut hits = extract_search_hits(&html);
|
||||||
|
|
||||||
if hits.is_empty() && final_url.host_str().is_some() {
|
if hits.is_empty() && final_url.host_str().is_some() {
|
||||||
hits = extract_search_hits_from_generic_links(&html);
|
hits = extract_search_hits_from_generic_links(&html, search_url.host_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(allowed) = input.allowed_domains.as_ref() {
|
if let Some(allowed) = input.allowed_domains.as_ref() {
|
||||||
@@ -3760,7 +3770,7 @@ fn extract_search_hits(html: &str) -> Vec<SearchHit> {
|
|||||||
hits
|
hits
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
|
fn extract_search_hits_from_generic_links(html: &str, search_host: Option<&str>) -> Vec<SearchHit> {
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
let mut remaining = html;
|
let mut remaining = html;
|
||||||
|
|
||||||
@@ -3791,6 +3801,11 @@ fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
|
|||||||
}
|
}
|
||||||
let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
|
let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
|
||||||
if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
|
if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
|
||||||
|
if is_search_engine_link(&decoded_url, search_host) {
|
||||||
|
remaining = &after_tag[end_anchor_idx + 4..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
hits.push(SearchHit {
|
hits.push(SearchHit {
|
||||||
title: title.trim().to_string(),
|
title: title.trim().to_string(),
|
||||||
url: decoded_url,
|
url: decoded_url,
|
||||||
@@ -3873,6 +3888,20 @@ fn normalize_domain_filter(domain: &str) -> String {
|
|||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A generic-fallback link that points back at DuckDuckGo or at the configured
|
||||||
|
/// search host is page chrome (home, settings, challenge), never a result.
|
||||||
|
fn is_search_engine_link(url: &str, search_host: Option<&str>) -> bool {
|
||||||
|
let Some(host) = reqwest::Url::parse(url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
host == "duckduckgo.com"
|
||||||
|
|| host.ends_with(".duckduckgo.com")
|
||||||
|
|| search_host.is_some_and(|search_host| host.eq_ignore_ascii_case(search_host))
|
||||||
|
}
|
||||||
|
|
||||||
fn dedupe_hits(hits: &mut Vec<SearchHit>) {
|
fn dedupe_hits(hits: &mut Vec<SearchHit>) {
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
hits.retain(|hit| seen.insert(hit.url.clone()));
|
hits.retain(|hit| seen.insert(hit.url.clone()));
|
||||||
@@ -8273,6 +8302,112 @@ mod tests {
|
|||||||
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
|
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_returns_error_on_non_2xx_response() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
assert!(request_line.contains("GET /search?q=test"));
|
||||||
|
// Simulate rate-limited/blocked response (HTTP 429)
|
||||||
|
HttpResponse::html(
|
||||||
|
429,
|
||||||
|
"Too Many Requests",
|
||||||
|
"<html><body>\n <p>Too many requests. Please try again later.</p>\n </body></html>",
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||||
|
format!("http://{}/search", server.addr()),
|
||||||
|
);
|
||||||
|
let error = execute_tool("WebSearch", &json!({ "query": "test" }))
|
||||||
|
.expect_err("non-2xx response should fail");
|
||||||
|
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||||
|
|
||||||
|
assert!(error.contains("Web search unavailable"));
|
||||||
|
assert!(error.contains("429"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_rejects_duckduckgo_202_challenge_page() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
assert!(request_line.contains("GET /html/?q=challenge "));
|
||||||
|
HttpResponse::html(
|
||||||
|
202,
|
||||||
|
"Accepted",
|
||||||
|
r#"
|
||||||
|
<html><body>
|
||||||
|
<a href="/html/">DuckDuckGo</a>
|
||||||
|
<a href="https://duckduckgo.com/">Home</a>
|
||||||
|
<p>Unfortunately, bots use DuckDuckGo too.</p>
|
||||||
|
</body></html>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||||
|
format!("http://{}/html/", server.addr()),
|
||||||
|
);
|
||||||
|
let error = execute_tool("WebSearch", &json!({ "query": "challenge" }))
|
||||||
|
.expect_err("a 202 challenge page must not produce results");
|
||||||
|
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error.contains("Web search unavailable (HTTP 202)"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_search_generic_fallback_drops_search_engine_links() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
assert!(request_line.contains("GET /search?q=chrome+links "));
|
||||||
|
HttpResponse::html(
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
r#"
|
||||||
|
<html><body>
|
||||||
|
<a href="/html/">DuckDuckGo</a>
|
||||||
|
<a href="https://html.duckduckgo.com/html/?q=x">Next page</a>
|
||||||
|
<a href="https://duckduckgo.com/settings">Settings</a>
|
||||||
|
<a href="https://docs.rs/serde">Serde Docs</a>
|
||||||
|
<a href="https://tokio.rs/">Tokio</a>
|
||||||
|
</body></html>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||||
|
format!("http://{}/search", server.addr()),
|
||||||
|
);
|
||||||
|
let result = execute_tool("WebSearch", &json!({ "query": "chrome links" }))
|
||||||
|
.expect("WebSearch fallback parsing should succeed");
|
||||||
|
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||||
|
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||||
|
let content = output["results"]
|
||||||
|
.as_array()
|
||||||
|
.expect("results array")
|
||||||
|
.iter()
|
||||||
|
.find_map(|item| item.get("content"))
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.expect("search result block present");
|
||||||
|
let urls: Vec<&str> = content
|
||||||
|
.iter()
|
||||||
|
.map(|hit| hit["url"].as_str().expect("url"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(urls, ["https://docs.rs/serde", "https://tokio.rs/"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
|
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|||||||
Reference in new issue
Block a user