3136 lines
102 KiB
Rust
3136 lines
102 KiB
Rust
mod common;
|
||
|
||
use std::fs;
|
||
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,
|
||
handle_browser_message_with_context,
|
||
AgentRuntimeContext,
|
||
};
|
||
use sgclaw::compat::runtime::{
|
||
execute_task,
|
||
execute_task_with_sgclaw_settings,
|
||
CompatTaskContext,
|
||
};
|
||
use sgclaw::config::{DeepSeekSettings, SgClawSettings};
|
||
use sgclaw::pipe::{
|
||
Action, AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, Timing,
|
||
};
|
||
use sgclaw::runtime::RuntimeProfile;
|
||
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 {
|
||
policy_for_domains(&["www.baidu.com"])
|
||
}
|
||
|
||
fn zhihu_test_policy() -> MacPolicy {
|
||
policy_for_domains(&["www.zhihu.com", "zhuanlan.zhihu.com"])
|
||
}
|
||
|
||
fn policy_for_domains(domains: &[&str]) -> MacPolicy {
|
||
MacPolicy::from_json_str(
|
||
&json!({
|
||
"version": "1.0",
|
||
"domains": { "allowed": domains },
|
||
"pipe_actions": {
|
||
"allowed": ["click", "type", "navigate", "getText", "waitForSelector", "eval"],
|
||
"blocked": []
|
||
}
|
||
})
|
||
.to_string(),
|
||
)
|
||
.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 write_deepseek_config(root: &PathBuf, api_key: &str, base_url: &str, model: &str) -> PathBuf {
|
||
write_deepseek_config_with_skills_dir(root, api_key, base_url, model, None)
|
||
}
|
||
|
||
fn write_deepseek_config_with_skills_dir(
|
||
root: &PathBuf,
|
||
api_key: &str,
|
||
base_url: &str,
|
||
model: &str,
|
||
skills_dir: Option<&str>,
|
||
) -> PathBuf {
|
||
let config_path = root.join("sgclaw_config.json");
|
||
let mut payload = json!({
|
||
"apiKey": api_key,
|
||
"baseUrl": base_url,
|
||
"model": model,
|
||
});
|
||
if let Some(skills_dir) = skills_dir {
|
||
payload["skillsDir"] = json!(skills_dir);
|
||
}
|
||
|
||
fs::write(
|
||
&config_path,
|
||
serde_json::to_string_pretty(&payload).unwrap(),
|
||
)
|
||
.unwrap();
|
||
config_path
|
||
}
|
||
|
||
fn write_skill_package(skills_dir: &std::path::Path, skill_name: &str, body: &str) {
|
||
let skill_dir = skills_dir.join(skill_name);
|
||
fs::create_dir_all(&skill_dir).unwrap();
|
||
fs::write(skill_dir.join("SKILL.md"), body).unwrap();
|
||
}
|
||
|
||
fn write_skill_manifest_package(
|
||
skills_dir: &std::path::Path,
|
||
skill_name: &str,
|
||
manifest: &str,
|
||
) -> PathBuf {
|
||
let skill_dir = skills_dir.join(skill_name);
|
||
fs::create_dir_all(&skill_dir).unwrap();
|
||
fs::write(skill_dir.join("SKILL.toml"), manifest).unwrap();
|
||
skill_dir
|
||
}
|
||
|
||
fn write_skill_script(skill_dir: &std::path::Path, relative_path: &str, body: &str) {
|
||
let script_path = skill_dir.join(relative_path);
|
||
if let Some(parent) = script_path.parent() {
|
||
fs::create_dir_all(parent).unwrap();
|
||
}
|
||
fs::write(script_path, body).unwrap();
|
||
}
|
||
|
||
fn real_skill_lib_root() -> PathBuf {
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.parent()
|
||
.unwrap()
|
||
.join("skill_lib")
|
||
}
|
||
|
||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||
BrowserMessage::Response {
|
||
seq,
|
||
success: true,
|
||
data,
|
||
aom_snapshot: vec![],
|
||
timing: Timing {
|
||
queue_ms: 1,
|
||
exec_ms: 10,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn request_tool_names(request: &Value) -> Vec<String> {
|
||
request["tools"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter_map(|tool| tool["function"]["name"].as_str().map(str::to_string))
|
||
.collect::<Vec<_>>()
|
||
}
|
||
|
||
fn tool_message_content<'a>(request: &'a Value, tool_call_id: &str) -> Option<&'a str> {
|
||
request["messages"].as_array().and_then(|messages| {
|
||
messages.iter().find_map(|message| {
|
||
(message["role"].as_str() == Some("tool")
|
||
&& message["tool_call_id"].as_str() == Some(tool_call_id))
|
||
.then(|| message["content"].as_str())
|
||
.flatten()
|
||
})
|
||
})
|
||
}
|
||
|
||
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 settings = DeepSeekSettings::from_env().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,
|
||
},
|
||
},
|
||
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,
|
||
"打开百度搜索天气",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.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!(request_tool_names(&request_bodies[0]).contains(&"browser_action".to_string()));
|
||
assert!(request_tool_names(&request_bodies[0]).contains(&"superrpa_browser".to_string()));
|
||
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 compat_runtime_includes_default_workspace_skills_in_provider_request() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已识别默认 workspace skill"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
write_skill_package(
|
||
&default_skills_dir,
|
||
"workspace-zhihu-skill",
|
||
"# Workspace Zhihu Skill\nUse this workspace-local skill.\n",
|
||
);
|
||
|
||
let settings = DeepSeekSettings {
|
||
api_key: "deepseek-test-key".to_string(),
|
||
base_url,
|
||
model: "deepseek-chat".to_string(),
|
||
skills_dir: None,
|
||
};
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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,
|
||
"列出当前可用 skill",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
|
||
assert_eq!(summary, "已识别默认 workspace skill");
|
||
assert_eq!(request_bodies.len(), 1);
|
||
assert!(request_bodies[0].to_string().contains("workspace-zhihu-skill"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_loads_skills_from_configured_skills_dir() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已识别自定义 skill 目录"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
write_skill_package(
|
||
&default_skills_dir,
|
||
"workspace-only-skill",
|
||
"# Workspace Only Skill\nThis skill should be ignored when skillsDir is set.\n",
|
||
);
|
||
|
||
let custom_skill_repo = workspace_root.join("skill_lib");
|
||
let custom_skills_dir = custom_skill_repo.join("skills");
|
||
write_skill_package(
|
||
&custom_skills_dir,
|
||
"configured-zhihu-skill",
|
||
"# Configured Zhihu Skill\nUse the configured skills directory.\n",
|
||
);
|
||
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some("skill_lib"),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "告诉我当前有哪些 zhihu skill".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let first_request = request_bodies[0].to_string();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已识别自定义 skill 目录"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" &&
|
||
message.contains("loaded skills: configured-zhihu-skill@0.1.0")
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 1);
|
||
assert!(first_request.contains("configured-zhihu-skill"));
|
||
assert!(!first_request.contains("workspace-only-skill"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_llm_is_configured(
|
||
) {
|
||
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()
|
||
}
|
||
},
|
||
{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "click",
|
||
"expected_domain": "www.baidu.com",
|
||
"selector": "#su"
|
||
})).unwrap()
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已在百度搜索天气"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let config_path = write_deepseek_config(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
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: 10,
|
||
},
|
||
},
|
||
BrowserMessage::Response {
|
||
seq: 3,
|
||
success: true,
|
||
data: json!({ "clicked": 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_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开百度搜索天气".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已在百度搜索天气"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" &&
|
||
message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "compat_llm_primary"
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "deterministic_planner"
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_emits_plan_preview_before_runtime_execution() {
|
||
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": "已打开百度首页"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, _requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let config_path = workspace_root.join("sgclaw_config.json");
|
||
fs::write(
|
||
&config_path,
|
||
serde_json::to_string_pretty(&json!({
|
||
"apiKey": "deepseek-test-key",
|
||
"baseUrl": base_url,
|
||
"model": "deepseek-chat",
|
||
"plannerMode": "zeroclawPlanFirst"
|
||
}))
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
json!({ "navigated": true }),
|
||
)]));
|
||
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_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开百度".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let preview_index = sent
|
||
.iter()
|
||
.position(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "plan" && message.contains("navigate https://www.baidu.com")
|
||
)
|
||
})
|
||
.expect("expected plan preview log entry");
|
||
let navigate_index = sent
|
||
.iter()
|
||
.position(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "navigate https://www.baidu.com"
|
||
)
|
||
})
|
||
.expect("expected runtime navigate log entry");
|
||
|
||
assert!(preview_index < navigate_index);
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instruction() {
|
||
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, 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 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(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
std::env::set_current_dir(original_dir).unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "来自 ZeroClaw runtime"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "compat_llm_primary"
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() {
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
assert!(matches!(
|
||
sent.last(),
|
||
Some(AgentMessage::TaskComplete { success, summary })
|
||
if !success && summary.contains("未配置大语言模型")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() {
|
||
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.zhihu.com",
|
||
"url": "https://www.zhihu.com/search?q=天气&type=content"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已在知乎搜索天气"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let settings = DeepSeekSettings {
|
||
api_key: "deepseek-test-key".to_string(),
|
||
base_url,
|
||
model: "deepseek-chat".to_string(),
|
||
skills_dir: None,
|
||
};
|
||
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));
|
||
|
||
let task_context = CompatTaskContext {
|
||
conversation_id: Some("conversation-1".to_string()),
|
||
messages: vec![
|
||
ConversationMessage {
|
||
role: "user".to_string(),
|
||
content: "打开百度搜索天气".to_string(),
|
||
},
|
||
ConversationMessage {
|
||
role: "assistant".to_string(),
|
||
content: "已在百度搜索天气".to_string(),
|
||
},
|
||
],
|
||
page_url: Some("https://www.zhihu.com/".to_string()),
|
||
page_title: Some("知乎".to_string()),
|
||
};
|
||
|
||
let summary = execute_task(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"打开知乎搜索天气",
|
||
&task_context,
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let first_request_messages = request_bodies[0]["messages"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
|
||
assert_eq!(summary, "已在知乎搜索天气");
|
||
assert!(first_request_messages.iter().any(|message| {
|
||
message["role"] == json!("user")
|
||
&& message["content"] == json!("打开百度搜索天气")
|
||
}));
|
||
assert!(first_request_messages.iter().any(|message| {
|
||
message["role"] == json!("assistant")
|
||
&& message["content"] == json!("已在百度搜索天气")
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_does_not_forward_raw_aom_snapshot_back_to_provider() {
|
||
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, requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let settings = DeepSeekSettings {
|
||
api_key: "deepseek-test-key".to_string(),
|
||
base_url,
|
||
model: "deepseek-chat".to_string(),
|
||
skills_dir: None,
|
||
};
|
||
|
||
let large_snapshot_marker = "snapshot-marker ".repeat(2048);
|
||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||
seq: 1,
|
||
success: true,
|
||
data: json!({ "navigated": true }),
|
||
aom_snapshot: vec![json!({
|
||
"role": "RootWebArea",
|
||
"name": "百度一下,你就知道",
|
||
"text": large_snapshot_marker
|
||
})],
|
||
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));
|
||
|
||
let summary = execute_task(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"打开百度首页",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let follow_up_request = request_bodies[1].to_string();
|
||
|
||
assert_eq!(summary, "来自 ZeroClaw runtime");
|
||
assert_eq!(request_bodies.len(), 2);
|
||
assert!(!follow_up_request.contains("snapshot-marker"));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_injects_browser_contract_and_page_context_into_provider_request() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已收到页面上下文"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let settings = DeepSeekSettings {
|
||
api_key: "deepseek-test-key".to_string(),
|
||
base_url,
|
||
model: "deepseek-chat".to_string(),
|
||
skills_dir: None,
|
||
};
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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 task_context = CompatTaskContext {
|
||
conversation_id: Some("conversation-ctx".to_string()),
|
||
messages: vec![],
|
||
page_url: Some("https://www.zhihu.com/hot".to_string()),
|
||
page_title: Some("知乎热榜".to_string()),
|
||
};
|
||
|
||
let summary = execute_task(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"统计一下知乎热帮信息",
|
||
&task_context,
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let first_request_messages = request_bodies[0]["messages"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let flattened = first_request_messages
|
||
.iter()
|
||
.filter_map(|message| message["content"].as_str())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert_eq!(summary, "已收到页面上下文");
|
||
assert!(
|
||
flattened.contains("expected_domain must be the bare hostname"),
|
||
"missing browser tool contract guidance: {flattened}"
|
||
);
|
||
assert!(
|
||
flattened.contains("document.querySelector"),
|
||
"missing CSS selector guidance: {flattened}"
|
||
);
|
||
assert!(
|
||
flattened.contains("https://www.zhihu.com/hot"),
|
||
"missing page url context: {flattened}"
|
||
);
|
||
assert!(
|
||
flattened.contains("知乎热榜"),
|
||
"missing page title context: {flattened}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "这是纯文本回答"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::GeneralAssistant;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"直接回答:你好",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let flattened = request_bodies[0]["messages"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|message| message["content"].as_str())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let tool_entries = request_bodies[0]["tools"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let tool_names = tool_entries
|
||
.into_iter()
|
||
.filter_map(|tool| tool["function"]["name"].as_str().map(str::to_string))
|
||
.collect::<Vec<_>>();
|
||
let sent = transport.sent_messages();
|
||
|
||
assert_eq!(summary, "这是纯文本回答");
|
||
assert!(!flattened.contains("Browser tool contract"));
|
||
assert!(!tool_names.contains(&"browser_action".to_string()));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(message, AgentMessage::Command { .. })
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已看到 compact skill 工具"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
write_skill_package(
|
||
&default_skills_dir,
|
||
"workspace-zhihu-skill",
|
||
"# Workspace Zhihu Skill\nUse this workspace-local skill.\n",
|
||
);
|
||
|
||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"告诉我当前有哪些 skill",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_entries = request_bodies[0]["tools"]
|
||
.as_array()
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let tool_names = tool_entries
|
||
.into_iter()
|
||
.filter_map(|tool| tool["function"]["name"].as_str().map(str::to_string))
|
||
.collect::<Vec<_>>();
|
||
|
||
assert_eq!(summary, "已看到 compact skill 工具");
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
assert!(!tool_names.contains(&"zhihu-hotlist_extract_hotlist".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_exposes_browser_script_skill_tools_in_browser_attached_mode() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已看到 browser_script skill 工具"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
let skill_dir = write_skill_manifest_package(
|
||
&default_skills_dir,
|
||
"workspace-zhihu-skill",
|
||
r#"
|
||
[skill]
|
||
name = "workspace-zhihu-skill"
|
||
description = "Extract Zhihu hotlist rows with a packaged browser script."
|
||
version = "0.1.0"
|
||
|
||
[[tools]]
|
||
name = "extract_hotlist"
|
||
description = "Extract structured hotlist rows from the current Zhihu page."
|
||
kind = "browser_script"
|
||
command = "scripts/extract_hotlist.js"
|
||
|
||
[tools.args]
|
||
top_n = "How many hotlist rows to extract."
|
||
"#,
|
||
);
|
||
write_skill_script(
|
||
&skill_dir,
|
||
"scripts/extract_hotlist.js",
|
||
"return { rows: [] };",
|
||
);
|
||
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"告诉我当前有哪些知乎热榜工具",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已看到 browser_script skill 工具");
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
assert!(tool_names.contains(&"workspace-zhihu-skill_extract_hotlist".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_executes_browser_script_skill_via_eval_without_gettext_probing() {
|
||
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": "workspace-zhihu-skill_extract_hotlist",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"expected_domain": "www.zhihu.com",
|
||
"top_n": "10"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已执行 browser_script skill"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
let skill_dir = write_skill_manifest_package(
|
||
&default_skills_dir,
|
||
"workspace-zhihu-skill",
|
||
r#"
|
||
[skill]
|
||
name = "workspace-zhihu-skill"
|
||
description = "Extract Zhihu hotlist rows with a packaged browser script."
|
||
version = "0.1.0"
|
||
|
||
[[tools]]
|
||
name = "extract_hotlist"
|
||
description = "Extract structured hotlist rows from the current Zhihu page."
|
||
kind = "browser_script"
|
||
command = "scripts/extract_hotlist.js"
|
||
|
||
[tools.args]
|
||
top_n = "How many hotlist rows to extract."
|
||
"#,
|
||
);
|
||
write_skill_script(
|
||
&skill_dir,
|
||
"scripts/extract_hotlist.js",
|
||
r#"
|
||
const topN = Number(args.top_n || 10);
|
||
return {
|
||
source: "https://www.zhihu.com/hot",
|
||
sheet_name: "知乎热榜",
|
||
columns: ["rank", "title", "heat"],
|
||
rows: [[1, "标题", `${topN}条`]]
|
||
};
|
||
"#,
|
||
);
|
||
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
json!({
|
||
"text": {
|
||
"source": "https://www.zhihu.com/hot",
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [[1, "标题", "10条"]]
|
||
}
|
||
}),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"用知乎热榜 skill 提取前十条结构化数据",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已执行 browser_script skill");
|
||
assert!(tool_names.contains(&"workspace-zhihu-skill_extract_hotlist".to_string()));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(message, AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "call workspace-zhihu-skill.extract_hotlist")
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(message, AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message.starts_with("getText "))
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已准备好知乎热榜技能"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
Some(real_skill_lib_root()),
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"统计一下知乎热榜前十,给出标题和热度",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已准备好知乎热榜技能");
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
assert!(tool_names.contains(&"zhihu-hotlist_extract_hotlist".to_string()));
|
||
assert!(!tool_names.contains(&"shell".to_string()));
|
||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_browser_attached_profile_keeps_file_read_available_for_local_paths() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已收到本地路径任务"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"请读取本地文件 /home/zyl/data/report.md 的内容并总结",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已收到本地路径任务");
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"file_read".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已准备好知乎导出流程"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
Some(real_skill_lib_root()),
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"读取知乎热榜数据,并导出 excel 文件",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已准备好知乎导出流程");
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
assert!(tool_names.contains(&"zhihu-hotlist_extract_hotlist".to_string()));
|
||
assert!(tool_names.contains(&"openxml_office".to_string()));
|
||
assert!(!tool_names.contains(&"shell".to_string()));
|
||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已准备好知乎热榜大屏流程"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
Some(real_skill_lib_root()),
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"读取知乎热榜数据并生成领导演示大屏,在新标签页展示",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert_eq!(summary, "已准备好知乎热榜大屏流程");
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
assert!(tool_names.contains(&"zhihu-hotlist_extract_hotlist".to_string()));
|
||
assert!(tool_names.contains(&"screen_html_export".to_string()));
|
||
assert!(!tool_names.contains(&"shell".to_string()));
|
||
assert!(!tool_names.contains(&"glob_search".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn compat_runtime_logs_read_skill_usage_with_skill_name() {
|
||
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": "read_skill",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"name": "workspace-zhihu-skill"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已读取完整 skill"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, _requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let default_skills_dir = workspace_root.join(".sgclaw-zeroclaw-workspace").join("skills");
|
||
write_skill_package(
|
||
&default_skills_dir,
|
||
"workspace-zhihu-skill",
|
||
"# Workspace Zhihu Skill\nUse this workspace-local skill.\n",
|
||
);
|
||
|
||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
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_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"需要完整 zhihu skill 时先读取 skill",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
assert_eq!(summary, "已读取完整 skill");
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" &&
|
||
message == "read_skill workspace-zhihu-skill@0.1.0"
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已看到真实知乎 skill"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let skills_dir = real_skill_lib_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(skills_dir.to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "告诉我当前有哪些知乎 skill".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://example.net/".to_string(),
|
||
page_title: "Example Domain".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let first_request = request_bodies[0].to_string();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已看到真实知乎 skill"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" &&
|
||
message ==
|
||
"loaded skills: office-export-xlsx@0.1.0, zhihu-hotlist@0.1.0, zhihu-hotlist-screen@0.1.0, zhihu-navigate@0.1.0, zhihu-write@0.1.0"
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 1);
|
||
assert!(first_request.contains("office-export-xlsx"));
|
||
assert!(first_request.contains("zhihu-hotlist"));
|
||
assert!(first_request.contains("zhihu-hotlist-screen"));
|
||
assert!(first_request.contains("zhihu-navigate"));
|
||
assert!(first_request.contains("zhihu-write"));
|
||
assert!(tool_names.contains(&"browser_action".to_string()));
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"read_skill".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuffing() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已收到知乎导出任务"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"deepseek-test-key".to_string(),
|
||
base_url,
|
||
"deepseek-chat".to_string(),
|
||
Some(real_skill_lib_root()),
|
||
)
|
||
.unwrap();
|
||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
let summary = execute_task_with_sgclaw_settings(
|
||
transport.as_ref(),
|
||
browser_tool,
|
||
"读取知乎热榜数据,并导出 excel 文件",
|
||
&CompatTaskContext::default(),
|
||
&workspace_root,
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let first_request = request_bodies[0].to_string();
|
||
|
||
assert_eq!(summary, "已收到知乎导出任务");
|
||
assert!(first_request.contains("Zhihu hotlist execution contract"));
|
||
assert!(first_request.contains("Export completion contract"));
|
||
assert!(first_request.contains("openxml_office"));
|
||
assert!(!first_request.contains("Preloaded skill context:"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
|
||
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": "zhihu-hotlist_extract_hotlist",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"expected_domain": "www.zhihu.com",
|
||
"top_n": "10"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已完成知乎热榜采集"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) =
|
||
start_fake_deepseek_server(vec![first_response, second_response]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let skills_dir = real_skill_lib_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(skills_dir.to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(1, json!({
|
||
"text": {
|
||
"source": "https://www.zhihu.com/hot",
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [[1, "热榜项目 1", "1707万"], [2, "热榜项目 2", "1150万"]]
|
||
}
|
||
}))]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "统计一下知乎热榜前十".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/".to_string(),
|
||
page_title: "知乎".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_content = tool_message_content(&request_bodies[1], "call_1").unwrap();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已完成知乎热榜采集"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Eval &&
|
||
params["script"].as_str().unwrap_or_default().contains("columns: ['rank', 'title', 'heat']")
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 2);
|
||
assert!(tool_content.contains("知乎热榜"));
|
||
assert!(tool_content.contains("rank"));
|
||
assert!(tool_content.contains("heat"));
|
||
assert!(tool_content.contains("热榜项目 1"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_chains_hotlist_skill_into_office_export_tool() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let output_path = workspace_root.join("out/zhihu-hotlist.xlsx");
|
||
let output_path_str = output_path.to_string_lossy().to_string();
|
||
let first_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "zhihu-hotlist_extract_hotlist",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"expected_domain": "www.zhihu.com",
|
||
"top_n": "10"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "openxml_office",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [
|
||
[1, "问题一", "344万"],
|
||
[2, "问题二", "266万"]
|
||
],
|
||
"output_path": output_path_str
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let fourth_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
third_response,
|
||
fourth_response,
|
||
]);
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({
|
||
"text": {
|
||
"source": "https://www.zhihu.com/hot",
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
|
||
}
|
||
})),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜数据,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/".to_string(),
|
||
page_title: "知乎".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary.contains("已导出知乎热榜 Excel") && summary.contains(".xlsx")
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "call zhihu-hotlist.extract_hotlist"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, .. } if action == &Action::Eval
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let output_path = workspace_root.join("out/zhihu-hotlist-screen.html");
|
||
let output_path_str = output_path.to_string_lossy().to_string();
|
||
let first_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "navigate",
|
||
"expected_domain": "www.zhihu.com",
|
||
"url": "https://www.zhihu.com/hot"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "getText",
|
||
"expected_domain": "www.zhihu.com",
|
||
"selector": "main"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "screen_html_export",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"rows": [
|
||
[1, "问题一", "344万"],
|
||
[2, "问题二", "266万"]
|
||
],
|
||
"output_path": output_path_str
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let fourth_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": format!("已生成知乎热榜大屏 {output_path_str}")
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
fourth_response,
|
||
]);
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({ "navigated": true })),
|
||
success_browser_response(
|
||
2,
|
||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
|
||
),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜数据并生成领导演示大屏,在新标签页展示".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/".to_string(),
|
||
page_title: "知乎".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary.contains("已生成知乎热榜大屏") && summary.contains(".html")
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchestration() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let output_path = workspace_root.join("out/zhihu-hotlist-orchestrated.xlsx");
|
||
let output_path_str = output_path.to_string_lossy().to_string();
|
||
let first_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "navigate",
|
||
"expected_domain": "www.zhihu.com",
|
||
"url": "https://www.zhihu.com/hot"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "getText",
|
||
"expected_domain": "www.zhihu.com",
|
||
"selector": "main"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "openxml_office",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [
|
||
[1, "问题一", "344万"],
|
||
[2, "问题二", "266万"],
|
||
[3, "问题三", "181万"]
|
||
],
|
||
"output_path": output_path_str
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let fourth_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, _requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
fourth_response,
|
||
]);
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({ "navigated": true })),
|
||
success_browser_response(
|
||
2,
|
||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度\n3\n问题三\n181万热度" }),
|
||
),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜前10,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||
page_title: "知乎热榜".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
let summary = sent
|
||
.iter()
|
||
.find_map(|message| match message {
|
||
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
|
||
_ => None,
|
||
})
|
||
.expect("expected successful task completion");
|
||
|
||
assert!(summary.contains(".xlsx"));
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
"http://127.0.0.1:9",
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜前10,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||
page_title: "知乎热榜".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_orchestration_registers_superrpa_tools_natively() {
|
||
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": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "getText",
|
||
"expected_domain": "www.zhihu.com",
|
||
"selector": "main"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "openxml_office",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [[1, "问题一", "344万"]]
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已导出知乎热榜 Excel"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({ "text": "知乎热榜\n1\n问题一\n344万热度" })),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜前10,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||
page_title: "知乎热榜".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let sent = transport.sent_messages();
|
||
assert!(
|
||
!request_bodies.is_empty(),
|
||
"expected provider request, sent messages were: {sent:?}"
|
||
);
|
||
server_handle.join().unwrap();
|
||
let first_request = request_bodies
|
||
.first()
|
||
.expect("expected first provider request")
|
||
.to_string();
|
||
let tool_names = request_tool_names(&request_bodies[0]);
|
||
|
||
assert!(first_request.contains("superrpa_browser"));
|
||
assert!(tool_names.contains(&"superrpa_browser".to_string()));
|
||
assert!(tool_names.contains(&"openxml_office".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn zhihu_export_does_not_use_frontend_owned_mainline() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
"http://127.0.0.1:9",
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取热榜前10,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||
page_title: "知乎热榜".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" &&
|
||
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_skill_usage_is_execution_not_prompt_only() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let output_path = workspace_root.join("out/zhihu-hotlist-execution.xlsx");
|
||
let output_path_str = output_path.to_string_lossy().to_string();
|
||
let first_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "navigate",
|
||
"expected_domain": "www.zhihu.com",
|
||
"url": "https://www.zhihu.com/hot"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "superrpa_browser",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "getText",
|
||
"expected_domain": "www.zhihu.com",
|
||
"selector": "main"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "openxml_office",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"sheet_name": "知乎热榜",
|
||
"columns": ["rank", "title", "heat"],
|
||
"rows": [
|
||
[1, "问题一", "344万"],
|
||
[2, "问题二", "266万"]
|
||
],
|
||
"output_path": output_path_str
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let fourth_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": format!("已导出知乎热榜 Excel {output_path_str}")
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
fourth_response,
|
||
]);
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(real_skill_lib_root().to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({ "navigated": true })),
|
||
success_browser_response(
|
||
2,
|
||
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
|
||
),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "读取知乎热榜前10,并导出 excel 文件".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/hot".to_string(),
|
||
page_title: "知乎热榜".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let sent = transport.sent_messages();
|
||
let first_request = request_bodies
|
||
.first()
|
||
.expect("expected first provider request")
|
||
.to_string();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary.contains(".xlsx")
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message.starts_with("read_skill ")
|
||
)
|
||
}));
|
||
assert!(!sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" &&
|
||
(message == "getText .HotList-item" ||
|
||
message == "getText [data-hot-item]" ||
|
||
message == "getText ol li")
|
||
)
|
||
}));
|
||
assert!(!first_request.contains("Preloaded skill context:"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_executes_real_zhihu_navigate_skill_flow() {
|
||
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": "read_skill",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"name": "zhihu-navigate"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "navigate",
|
||
"expected_domain": "www.zhihu.com",
|
||
"url": "https://www.zhihu.com/hot"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已打开知乎热榜"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let skills_dir = real_skill_lib_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(skills_dir.to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
json!({ "navigated": true }),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开知乎热榜".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/".to_string(),
|
||
page_title: "知乎".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_content = tool_message_content(&request_bodies[1], "call_1").unwrap();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已打开知乎热榜"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "read_skill zhihu-navigate@0.1.0"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Navigate &&
|
||
params["url"].as_str() == Some("https://www.zhihu.com/hot")
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 3);
|
||
assert!(tool_content.len() > 100);
|
||
assert!(tool_content.contains("Zhihu page"));
|
||
}
|
||
|
||
#[test]
|
||
fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
|
||
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": "read_skill",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"name": "zhihu-write"
|
||
})).unwrap()
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
});
|
||
let second_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "",
|
||
"tool_calls": [
|
||
{
|
||
"id": "call_2",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "navigate",
|
||
"expected_domain": "www.zhihu.com",
|
||
"url": "https://www.zhihu.com/creator"
|
||
})).unwrap()
|
||
}
|
||
},
|
||
{
|
||
"id": "call_3",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "click",
|
||
"expected_domain": "www.zhihu.com",
|
||
"selector": "a[href='https://zhuanlan.zhihu.com/write']"
|
||
})).unwrap()
|
||
}
|
||
},
|
||
{
|
||
"id": "call_4",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "type",
|
||
"expected_domain": "zhuanlan.zhihu.com",
|
||
"selector": "input[placeholder='请输入标题']",
|
||
"text": "测试标题",
|
||
"clear_first": true
|
||
})).unwrap()
|
||
}
|
||
},
|
||
{
|
||
"id": "call_5",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "browser_action",
|
||
"arguments": serde_json::to_string(&json!({
|
||
"action": "type",
|
||
"expected_domain": "zhuanlan.zhihu.com",
|
||
"selector": ".public-DraftEditor-content",
|
||
"text": "第一段内容",
|
||
"clear_first": true
|
||
})).unwrap()
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}]
|
||
});
|
||
let third_response = json!({
|
||
"choices": [{
|
||
"message": {
|
||
"content": "已完成知乎文章草稿填写"
|
||
}
|
||
}]
|
||
});
|
||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![
|
||
first_response,
|
||
second_response,
|
||
third_response,
|
||
]);
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let skills_dir = real_skill_lib_root();
|
||
let config_path = write_deepseek_config_with_skills_dir(
|
||
&workspace_root,
|
||
"deepseek-test-key",
|
||
&base_url,
|
||
"deepseek-chat",
|
||
Some(skills_dir.to_str().unwrap()),
|
||
);
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![
|
||
success_browser_response(1, json!({ "navigated": true })),
|
||
success_browser_response(2, json!({ "clicked": true })),
|
||
success_browser_response(3, json!({ "typed": true })),
|
||
success_browser_response(4, json!({ "typed": true })),
|
||
]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
zhihu_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "帮我写一篇知乎文章,标题是测试标题,正文是第一段内容".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://www.zhihu.com/creator".to_string(),
|
||
page_title: "知乎创作中心".to_string(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
server_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let request_bodies = requests.lock().unwrap().clone();
|
||
let tool_content = tool_message_content(&request_bodies[1], "call_1").unwrap();
|
||
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::TaskComplete { success, summary }
|
||
if *success && summary == "已完成知乎文章草稿填写"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "info" && message == "read_skill zhihu-write@0.1.0"
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Navigate &&
|
||
params["url"].as_str() == Some("https://www.zhihu.com/creator")
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Click &&
|
||
params["selector"].as_str() ==
|
||
Some("a[href='https://zhuanlan.zhihu.com/write']")
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Type &&
|
||
params["selector"].as_str() == Some("input[placeholder='请输入标题']")
|
||
)
|
||
}));
|
||
assert!(sent.iter().any(|message| {
|
||
matches!(
|
||
message,
|
||
AgentMessage::Command { action, params, .. }
|
||
if action == &Action::Type &&
|
||
params["selector"].as_str() == Some(".public-DraftEditor-content")
|
||
)
|
||
}));
|
||
assert_eq!(request_bodies.len(), 3);
|
||
assert!(tool_content.len() > 100);
|
||
assert!(tool_content.contains("publish a Zhihu article"));
|
||
}
|