feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -82,3 +83,13 @@ fn browser_tool_rejects_action_when_mac_policy_blocks_it() {
|
||||
|
||||
assert!(err.to_string().contains("action is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_rules_allow_zhihu_navigation() {
|
||||
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("rules.json");
|
||||
let policy = MacPolicy::load_from_path(rules_path).unwrap();
|
||||
|
||||
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
|
||||
}
|
||||
|
||||
203
tests/compat_browser_tool_test.rs
Normal file
203
tests/compat_browser_tool_test.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use sgclaw::{
|
||||
compat::browser_tool_adapter::ZeroClawBrowserTool,
|
||||
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing},
|
||||
};
|
||||
use zeroclaw::tools::Tool;
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": ["eval", "executeJsInPage"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_adapter(messages: Vec<BrowserMessage>) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
|
||||
let transport = Arc::new(MockTransport::new(messages));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
(transport, ZeroClawBrowserTool::new(browser_tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeroclaw_browser_tool_schema_exposes_only_supported_safe_actions() {
|
||||
let (_, tool) = build_adapter(vec![]);
|
||||
let schema = tool.parameters_schema();
|
||||
|
||||
assert_eq!(tool.name(), "browser_action");
|
||||
assert_eq!(
|
||||
schema["properties"]["action"]["enum"],
|
||||
json!(["click", "type", "navigate", "getText"])
|
||||
);
|
||||
assert_eq!(schema["required"], json!(["action", "expected_domain"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zeroclaw_browser_tool_executes_supported_actions_and_returns_observation_payload() {
|
||||
let (transport, tool) = build_adapter(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 11,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: json!({ "typed": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 2,
|
||||
exec_ms: 12,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 3,
|
||||
success: true,
|
||||
data: json!({ "clicked": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 3,
|
||||
exec_ms: 13,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 4,
|
||||
success: true,
|
||||
data: json!({ "text": "天气" }),
|
||||
aom_snapshot: vec![json!({
|
||||
"role": "textbox",
|
||||
"name": "百度一下"
|
||||
})],
|
||||
timing: Timing {
|
||||
queue_ms: 4,
|
||||
exec_ms: 14,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let navigate = tool
|
||||
.execute(json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"url": "https://www.baidu.com"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let type_text = tool
|
||||
.execute(json!({
|
||||
"action": "type",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#kw",
|
||||
"text": "天气",
|
||||
"clear_first": true
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let click = tool
|
||||
.execute(json!({
|
||||
"action": "click",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#su"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let get_text = tool
|
||||
.execute(json!({
|
||||
"action": "getText",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#content_left"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let navigate_output: Value = serde_json::from_str(&navigate.output).unwrap();
|
||||
let get_text_output: Value = serde_json::from_str(&get_text.output).unwrap();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert!(navigate.success);
|
||||
assert!(type_text.success);
|
||||
assert!(click.success);
|
||||
assert!(get_text.success);
|
||||
assert_eq!(navigate_output["data"], json!({ "navigated": true }));
|
||||
assert_eq!(get_text_output["data"], json!({ "text": "天气" }));
|
||||
assert_eq!(
|
||||
get_text_output["aom_snapshot"],
|
||||
json!([{ "role": "textbox", "name": "百度一下" }])
|
||||
);
|
||||
assert_eq!(
|
||||
get_text_output["timing"],
|
||||
json!({
|
||||
"queue_ms": 4,
|
||||
"exec_ms": 14
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 1 && action == &Action::Navigate
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 2 && action == &Action::Type
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[2],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 3 && action == &Action::Click
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[3],
|
||||
AgentMessage::Command { seq, action, .. }
|
||||
if *seq == 4 && action == &Action::GetText
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zeroclaw_browser_tool_keeps_domain_validation_in_mac_policy() {
|
||||
let (transport, tool) = build_adapter(vec![]);
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"url": "https://www.zhihu.com"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result.output.is_empty());
|
||||
assert_eq!(transport.sent_messages().len(), 0);
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("domain is not allowed")
|
||||
);
|
||||
}
|
||||
55
tests/compat_config_test.rs
Normal file
55
tests/compat_config_test.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config,
|
||||
build_zeroclaw_config_from_settings,
|
||||
zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeroclaw_config_adapter_maps_deepseek_env_to_zeroclaw_config() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
std::env::set_var("DEEPSEEK_API_KEY", "deepseek-test-key");
|
||||
std::env::set_var("DEEPSEEK_BASE_URL", "https://api.deepseek.com");
|
||||
std::env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
||||
|
||||
let config = build_zeroclaw_config(Path::new("/tmp/sgclaw")).unwrap();
|
||||
|
||||
assert_eq!(config.default_provider.as_deref(), Some("deepseek"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("deepseek-chat"));
|
||||
assert_eq!(config.api_key.as_deref(), Some("deepseek-test-key"));
|
||||
assert_eq!(config.api_url.as_deref(), Some("https://api.deepseek.com"));
|
||||
assert_eq!(
|
||||
config.workspace_dir,
|
||||
Path::new("/tmp/sgclaw/.sgclaw-zeroclaw-workspace")
|
||||
);
|
||||
assert_eq!(
|
||||
config.config_path,
|
||||
Path::new("/tmp/sgclaw/.sgclaw-zeroclaw-workspace/config.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://proxy.example.com/v1".to_string(),
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
};
|
||||
|
||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
||||
let config = build_zeroclaw_config_from_settings(Path::new("/var/lib/sgclaw"), &settings);
|
||||
|
||||
assert_eq!(workspace_dir, Path::new("/var/lib/sgclaw/.sgclaw-zeroclaw-workspace"));
|
||||
assert_eq!(config.workspace_dir, workspace_dir);
|
||||
assert_eq!(config.default_provider.as_deref(), Some("deepseek"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner"));
|
||||
assert_eq!(config.api_url.as_deref(), Some("https://proxy.example.com/v1"));
|
||||
}
|
||||
63
tests/compat_cron_test.rs
Normal file
63
tests/compat_cron_test.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Duration;
|
||||
use sgclaw::compat::config_adapter::build_zeroclaw_config_from_settings;
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
use zeroclaw::cron::Schedule;
|
||||
|
||||
fn workspace_root(label: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compat_cron_adapter_creates_lists_and_runs_due_agent_jobs() {
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-cron");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
assert!(config.cron.enabled);
|
||||
assert!(!config.cron.catch_up_on_startup);
|
||||
assert!(!config.scheduler.enabled);
|
||||
|
||||
let created = sgclaw::compat::cron_adapter::add_agent_job(
|
||||
&config,
|
||||
Some("search-weather".to_string()),
|
||||
Schedule::Every { every_ms: 1 },
|
||||
"打开百度搜索天气",
|
||||
Some(vec!["browser_action".to_string()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listed = sgclaw::compat::cron_adapter::list_jobs(&config).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, created.id);
|
||||
assert_eq!(listed[0].prompt.as_deref(), Some("打开百度搜索天气"));
|
||||
|
||||
let results = sgclaw::compat::cron_adapter::run_due_jobs(
|
||||
&config,
|
||||
created.next_run + Duration::milliseconds(1),
|
||||
|job| {
|
||||
let output = format!("ran {}", job.prompt.as_deref().unwrap_or_default());
|
||||
async move { Ok::<String, anyhow::Error>(output) }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let runs = sgclaw::compat::cron_adapter::list_runs(&config, &created.id, 10).unwrap();
|
||||
let updated = sgclaw::compat::cron_adapter::list_jobs(&config).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].success);
|
||||
assert_eq!(results[0].job_id, created.id);
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].status, "ok");
|
||||
assert!(updated[0].last_status.as_deref() == Some("ok"));
|
||||
assert!(updated[0].next_run > created.next_run);
|
||||
}
|
||||
42
tests/compat_memory_test.rs
Normal file
42
tests/compat_memory_test.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sgclaw::compat::config_adapter::build_zeroclaw_config_from_settings;
|
||||
use sgclaw::config::DeepSeekSettings;
|
||||
use zeroclaw::memory::MemoryCategory;
|
||||
|
||||
fn workspace_root(label: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compat_memory_adapter_uses_workspace_local_sqlite_backend() {
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-memory");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
assert_eq!(config.memory.backend, "sqlite");
|
||||
assert_eq!(config.memory.embedding_provider, "none");
|
||||
assert!(!config.memory.response_cache_enabled);
|
||||
assert!(!config.memory.snapshot_enabled);
|
||||
assert!(config.storage.provider.config.provider.is_empty());
|
||||
|
||||
let memory = sgclaw::compat::memory_adapter::build_memory(&config).unwrap();
|
||||
memory
|
||||
.store(
|
||||
"weather",
|
||||
"remember today's weather workflow",
|
||||
MemoryCategory::Conversation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(memory.count().await.unwrap(), 1);
|
||||
assert!(sgclaw::compat::memory_adapter::brain_db_path(&config.workspace_dir).exists());
|
||||
}
|
||||
334
tests/compat_runtime_test.rs
Normal file
334
tests/compat_runtime_test.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
mod common;
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::{json, Value};
|
||||
use sgclaw::agent::handle_browser_message;
|
||||
use sgclaw::compat::runtime::execute_task;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn test_policy() -> MacPolicy {
|
||||
MacPolicy::from_json_str(
|
||||
r#"{
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com"] },
|
||||
"pipe_actions": {
|
||||
"allowed": ["click", "type", "navigate", "getText"],
|
||||
"blocked": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-compat-runtime-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn start_fake_deepseek_server(
|
||||
responses: Vec<Value>,
|
||||
) -> (String, Arc<Mutex<Vec<Value>>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_log = requests.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
for response in responses {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let (mut stream, _) = loop {
|
||||
match listener.accept() {
|
||||
Ok(pair) => break pair,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for provider request"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(err) => panic!("failed to accept provider request: {err}"),
|
||||
}
|
||||
};
|
||||
let body = read_http_json_body(&mut stream);
|
||||
request_log.lock().unwrap().push(body);
|
||||
|
||||
let payload = response.to_string();
|
||||
let reply = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
payload.as_bytes().len(),
|
||||
payload
|
||||
);
|
||||
stream.write_all(reply.as_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
(address, requests, handle)
|
||||
}
|
||||
|
||||
fn read_http_json_body(stream: &mut impl Read) -> Value {
|
||||
let mut buffer = Vec::new();
|
||||
let mut headers_end = None;
|
||||
|
||||
while headers_end.is_none() {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let bytes = stream.read(&mut chunk).unwrap();
|
||||
assert!(bytes > 0, "unexpected EOF while reading headers");
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
headers_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
}
|
||||
|
||||
let headers_end = headers_end.unwrap() + 4;
|
||||
let headers = String::from_utf8(buffer[..headers_end].to_vec()).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
while buffer.len() < headers_end + content_length {
|
||||
let mut chunk = vec![0_u8; content_length];
|
||||
let bytes = stream.read(&mut chunk).unwrap();
|
||||
assert!(bytes > 0, "unexpected EOF while reading body");
|
||||
buffer.extend_from_slice(&chunk[..bytes]);
|
||||
}
|
||||
|
||||
serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_action",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"url": "https://www.baidu.com"
|
||||
})).unwrap()
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_action",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "type",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"selector": "#kw",
|
||||
"text": "天气",
|
||||
"clear_first": true
|
||||
})).unwrap()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 7
|
||||
}
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已通过 ZeroClaw 执行任务: 打开百度搜索天气"
|
||||
}
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 15,
|
||||
"completion_tokens": 8
|
||||
}
|
||||
});
|
||||
let (base_url, requests, server_handle) =
|
||||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
std::env::set_var("DEEPSEEK_API_KEY", "deepseek-test-key");
|
||||
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
|
||||
std::env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let transport = Arc::new(MockTransport::new(vec![
|
||||
BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
},
|
||||
BrowserMessage::Response {
|
||||
seq: 2,
|
||||
success: true,
|
||||
data: json!({ "typed": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 11,
|
||||
},
|
||||
},
|
||||
]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let summary = execute_task(
|
||||
transport.as_ref(),
|
||||
browser_tool,
|
||||
"打开百度搜索天气",
|
||||
&workspace_root,
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(summary, "已通过 ZeroClaw 执行任务: 打开百度搜索天气");
|
||||
assert_eq!(request_bodies.len(), 2);
|
||||
assert_eq!(request_bodies[0]["model"], json!("deepseek-chat"));
|
||||
assert_eq!(
|
||||
request_bodies[0]["tools"][0]["function"]["name"],
|
||||
json!("browser_action")
|
||||
);
|
||||
assert!(request_bodies[1].to_string().contains("tool_call_id"));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.baidu.com"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "type 天气 into #kw"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Navigate
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::Command { action, .. } if action == &Action::Type
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_uses_compat_runtime_summary_when_deepseek_env_is_set() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_action",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "navigate",
|
||||
"expected_domain": "www.baidu.com",
|
||||
"url": "https://www.baidu.com"
|
||||
})).unwrap()
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let second_response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "来自 ZeroClaw runtime"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, _, server_handle) = start_fake_deepseek_server(vec![first_response, second_response]);
|
||||
|
||||
std::env::set_var("DEEPSEEK_API_KEY", "deepseek-test-key");
|
||||
std::env::set_var("DEEPSEEK_BASE_URL", base_url);
|
||||
std::env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let original_dir = std::env::current_dir().unwrap();
|
||||
std::env::set_current_dir(&workspace_root).unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({ "navigated": true }),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 10,
|
||||
},
|
||||
}]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "打开百度搜索天气".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
std::env::set_current_dir(original_dir).unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "来自 ZeroClaw runtime"
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -30,6 +30,24 @@ fn planner_supports_baidu_search_variant_with_conjunction() {
|
||||
assert_eq!(plan.steps[1].params["text"], "电网调度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_supports_zhihu_search_instruction_with_direct_search_url() {
|
||||
let plan = plan_instruction("打开知乎搜索天气").unwrap();
|
||||
|
||||
assert_eq!(plan.summary, "已在知乎搜索天气");
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].action, Action::Navigate);
|
||||
assert_eq!(
|
||||
plan.steps[0].params,
|
||||
json!({ "url": "https://www.zhihu.com/search?type=content&q=%E5%A4%A9%E6%B0%94" })
|
||||
);
|
||||
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
|
||||
assert_eq!(
|
||||
plan.steps[0].log_message,
|
||||
"navigate https://www.zhihu.com/search?type=content&q=%E5%A4%A9%E6%B0%94"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_unrelated_instruction() {
|
||||
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
|
||||
|
||||
Reference in New Issue
Block a user