feat: align browser callback runtime and export flows

Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-06 21:44:53 +08:00
parent 0dd655712c
commit bdf8e12246
55 changed files with 14440 additions and 1053 deletions

View File

@@ -1,36 +1,167 @@
mod common;
use std::sync::Arc;
use std::fs;
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 sgclaw::agent::handle_browser_message;
use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider};
use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall};
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use serde_json::{json, Value};
use sgclaw::agent::{
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
};
use sgclaw::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::security::MacPolicy;
use tungstenite::{accept, Message};
use uuid::Uuid;
struct FakeProvider {
calls: Vec<ToolFunctionCall>,
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
impl LlmProvider for FakeProvider {
fn chat(
&self,
_messages: &[ChatMessage],
_tools: &[ToolDefinition],
) -> Result<Vec<ToolFunctionCall>, LlmError> {
Ok(self.calls.clone())
fn temp_workspace_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("sgclaw-agent-runtime-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
fn write_config(
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,
"runtimeProfile": "BrowserAttached"
});
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 real_skill_lib_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.find_map(|ancestor| {
let candidate = ancestor.join("skill_lib");
candidate.is_dir().then_some(candidate)
})
.expect("workspace should have sgClaw skill_lib ancestor")
}
fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
let frames_for_thread = Arc::clone(&frames);
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut socket = accept(stream).unwrap();
let mut action_count = 0_u64;
loop {
let message = match socket.read() {
Ok(message) => message,
Err(tungstenite::Error::ConnectionClosed)
| Err(tungstenite::Error::AlreadyClosed) => break,
Err(err) => panic!("browser ws test server read failed: {err}"),
};
let payload = match message {
Message::Text(text) => text.to_string(),
Message::Ping(payload) => {
socket.send(Message::Pong(payload)).unwrap();
continue;
}
Message::Close(_) => break,
other => panic!("expected text frame, got {other:?}"),
};
frames_for_thread.lock().unwrap().push(payload.clone());
let parsed: Value = serde_json::from_str(&payload).unwrap();
if parsed.get("type").and_then(Value::as_str) == Some("register") {
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
socket
.send(Message::Text(
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
.to_string()
.into(),
))
.unwrap();
socket.send(Message::Text("0".into())).unwrap();
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgHideBrowserCallAfterLoaded@_@"
)
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
r#"{"source":"https://www.zhihu.com/hot","sheet_name":"知乎热榜","columns":["rank","title","heat"],"rows":[[1,"问题一","344万"],[2,"问题二","266万"]]}"#.to_string()
};
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgBrowserExcuteJsCodeByArea@_@{response_text}"
)
])
}
other => panic!("unexpected browser action {other}"),
};
socket
.send(Message::Text(callback_frame.to_string().into()))
.unwrap();
if action_count >= 3 {
break;
}
}
});
(format!("ws://{address}"), frames, handle)
}
fn test_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["www.baidu.com"] },
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText"],
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
@@ -39,104 +170,131 @@ fn test_policy() -> MacPolicy {
}
#[test]
fn browser_action_tool_definition_uses_expected_name() {
let tool = browser_action_tool_definition();
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
assert_eq!(tool.name, "browser_action");
assert_eq!(tool.parameters["required"][0], "action");
assert_eq!(tool.parameters["required"][1], "expected_domain");
}
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
#[test]
fn runtime_executes_provider_tool_calls_and_returns_summary() {
let transport = Arc::new(MockTransport::new(vec![
BrowserMessage::Response {
seq: 1,
success: true,
data: serde_json::json!({ "navigated": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 10,
},
},
BrowserMessage::Response {
seq: 2,
success: true,
data: serde_json::json!({ "typed": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 10,
},
},
]));
let workspace_root = temp_workspace_root();
let config_path = write_config(
&workspace_root,
"deepseek-test-key",
"http://127.0.0.1:9",
"deepseek-chat",
Some(real_skill_lib_root().to_str().unwrap()),
);
let (ws_url, frames, ws_handle) = start_browser_ws_server();
std::env::set_var("SGCLAW_BROWSER_WS_URL", &ws_url);
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 provider = FakeProvider {
calls: vec![
ToolFunctionCall {
id: "call-1".to_string(),
name: "browser_action".to_string(),
arguments: serde_json::json!({
"action": "navigate",
"expected_domain": "www.baidu.com",
"url": "https://www.baidu.com"
}),
},
ToolFunctionCall {
id: "call-2".to_string(),
name: "browser_action".to_string(),
arguments: serde_json::json!({
"action": "type",
"expected_domain": "www.baidu.com",
"selector": "#kw",
"text": "天气",
"clear_first": true
}),
},
],
};
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
let summary = execute_task_with_provider(
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&provider,
"打开百度搜索天气",
&runtime_context,
BrowserMessage::SubmitTask {
instruction: "打开知乎热榜获取前10条数据并导出 Excel".to_string(),
conversation_id: String::new(),
messages: vec![],
page_url: String::new(),
page_title: String::new(),
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(summary, "已通过 Agent 执行任务: 打开百度搜索天气");
assert!(matches!(
&sent[0],
AgentMessage::LogEntry { level, message }
if level == "info" && message == "navigate www.baidu.com"
));
assert!(matches!(
&sent[1],
AgentMessage::Command { seq, action, .. }
if *seq == 1 && action == &Action::Navigate
));
assert!(matches!(
&sent[2],
AgentMessage::LogEntry { level, message }
if level == "info" && message == "type www.baidu.com"
));
assert!(matches!(
&sent[3],
AgentMessage::Command { seq, action, .. }
if *seq == 2 && action == &Action::Type
));
ws_handle.join().unwrap();
let sent = transport.sent_messages();
let websocket_frames = frames.lock().unwrap().clone();
assert_eq!(websocket_frames.len(), 4, "{websocket_frames:?}");
assert_eq!(websocket_frames[0], r#"{"type":"register","role":"web"}"#);
assert!(!websocket_frames
.iter()
.any(|frame| frame.contains("/sgclaw/browser-helper.html")));
assert!(!websocket_frames
.iter()
.any(|frame| frame.contains("\"sgBrowerserOpenPage\"")));
let navigate: Value = serde_json::from_str(&websocket_frames[1]).unwrap();
assert_eq!(navigate[0], json!("https://www.zhihu.com"));
assert_eq!(navigate[1], json!("sgHideBrowserCallAfterLoaded"));
assert_eq!(navigate[2], json!("https://www.zhihu.com/hot"));
let get_text: Value = serde_json::from_str(&websocket_frames[2]).unwrap();
assert_eq!(get_text[0], json!("https://www.zhihu.com/hot"));
assert_eq!(get_text[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(get_text[2], json!("https://www.zhihu.com/hot"));
let eval: Value = serde_json::from_str(&websocket_frames[3]).unwrap();
assert_eq!(eval[0], json!("https://www.zhihu.com/hot"));
assert_eq!(eval[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(eval[2], json!("https://www.zhihu.com/hot"));
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::TaskComplete { success, summary }
if *success && summary.contains("已导出并打开知乎热榜 Excel") && summary.contains(".xlsx")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
fn legacy_agent_runtime_is_explicitly_dev_only() {
assert!(sgclaw::agent::runtime::LEGACY_DEV_ONLY);
fn lifecycle_messages_emit_status_events_without_browser_commands() {
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));
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Connect)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Start)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Stop)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(
sent,
vec![
AgentMessage::StatusChanged {
state: "connected".to_string(),
},
AgentMessage::StatusChanged {
state: "started".to_string(),
},
AgentMessage::StatusChanged {
state: "stopped".to_string(),
},
]
);
assert!(!sent
.iter()
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
}
#[test]

View File

@@ -0,0 +1,145 @@
mod common;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use common::MockTransport;
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
use sgclaw::compat::browser_script_skill_tool::build_browser_script_skill_tools;
use sgclaw::pipe::{Action, CommandOutput, ExecutionSurfaceKind, ExecutionSurfaceMetadata};
use sgclaw::security::MacPolicy;
use zeroclaw::skills::{Skill, SkillTool};
fn backend_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["oa.example.com", "erp.example.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText"],
"blocked": ["eval", "executeJsInPage"]
}
}"#,
)
.unwrap()
}
fn eval_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["www.zhihu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
)
.unwrap()
}
#[test]
fn pipe_browser_backend_keeps_privileged_pipe_surface_metadata() {
let transport = Arc::new(MockTransport::new(vec![]));
let backend = PipeBrowserBackend::new(transport, backend_policy(), vec![1, 2, 3, 4]);
let metadata = backend.surface_metadata();
assert_eq!(metadata.kind, ExecutionSurfaceKind::PrivilegedBrowserPipe);
assert!(metadata.privileged);
assert!(!metadata.defines_runtime_identity);
assert_eq!(metadata.guard, "mac_policy");
assert_eq!(
metadata.allowed_domains,
vec!["oa.example.com", "erp.example.com"]
);
assert_eq!(
metadata.allowed_actions,
vec!["click", "type", "navigate", "getText"]
);
}
#[test]
fn pipe_browser_backend_reports_eval_capability_from_mac_policy() {
let transport = Arc::new(MockTransport::new(vec![]));
let backend = PipeBrowserBackend::new(transport, eval_policy(), vec![1, 2, 3, 4]);
assert!(backend.supports_eval());
}
#[test]
fn browser_script_tools_are_hidden_when_backend_cannot_eval() {
let skill_root = unique_temp_dir("sgclaw-browser-backend-capability");
let scripts_dir = skill_root.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(
scripts_dir.join("extract_hotlist.js"),
"return { rows: [[1, '标题', '10万热度']] };",
)
.unwrap();
let skills = vec![Skill {
name: "zhihu-hotlist".to_string(),
description: "Zhihu hotlist helpers".to_string(),
version: "1.0.0".to_string(),
author: None,
tags: vec![],
tools: vec![SkillTool {
name: "extract_hotlist".to_string(),
description: "Extract structured hotlist rows".to_string(),
kind: "browser_script".to_string(),
command: "scripts/extract_hotlist.js".to_string(),
args: HashMap::new(),
}],
prompts: vec![],
location: Some(skill_root.join("skill.json")),
}];
let backend: Arc<dyn BrowserBackend> = Arc::new(FakeBrowserBackend::new(false));
let tools = build_browser_script_skill_tools(&skills, backend).unwrap();
assert!(tools.is_empty());
}
#[derive(Default)]
struct FakeBrowserBackend {
supports_eval: bool,
}
impl FakeBrowserBackend {
fn new(supports_eval: bool) -> Self {
Self { supports_eval }
}
}
impl BrowserBackend for FakeBrowserBackend {
fn invoke(
&self,
_action: Action,
_params: serde_json::Value,
_expected_domain: &str,
) -> Result<CommandOutput, sgclaw::pipe::PipeError> {
panic!("invoke should not be called in this capability-gating test")
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
}
fn supports_eval(&self) -> bool {
self.supports_eval
}
}
fn unique_temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}

View File

@@ -0,0 +1,151 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use serde_json::json;
use sgclaw::browser::bridge_contract::{
BridgeBrowserActionError, BridgeBrowserActionReply, BridgeBrowserActionRequest,
BridgeBrowserActionSuccess,
};
use sgclaw::browser::bridge_transport::BridgeActionTransport;
use sgclaw::browser::{BridgeBrowserBackend, BrowserBackend};
use sgclaw::pipe::{Action, PipeError, Timing};
use sgclaw::security::MacPolicy;
fn test_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["www.baidu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
)
.unwrap()
}
struct FakeBridgeTransport {
requests: Mutex<Vec<BridgeBrowserActionRequest>>,
replies: Mutex<VecDeque<Result<BridgeBrowserActionReply, PipeError>>>,
}
impl FakeBridgeTransport {
fn new(replies: Vec<Result<BridgeBrowserActionReply, PipeError>>) -> Self {
Self {
requests: Mutex::new(Vec::new()),
replies: Mutex::new(replies.into()),
}
}
fn recorded_requests(&self) -> Vec<BridgeBrowserActionRequest> {
self.requests.lock().unwrap().clone()
}
}
impl BridgeActionTransport for FakeBridgeTransport {
fn execute(
&self,
request: BridgeBrowserActionRequest,
) -> Result<BridgeBrowserActionReply, PipeError> {
self.requests.lock().unwrap().push(request);
self.replies
.lock()
.unwrap()
.pop_front()
.unwrap_or(Err(PipeError::Timeout))
}
}
#[test]
fn bridge_backend_maps_navigate_to_bridge_action_request() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
data: json!({ "navigated": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 11,
},
}),
)]));
let backend = BridgeBrowserBackend::new(transport.clone(), test_policy());
let output = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap();
assert_eq!(
transport.recorded_requests(),
vec![BridgeBrowserActionRequest::new(
"navigate",
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)]
);
assert_eq!(output.seq, 1);
assert!(output.success);
}
#[test]
fn bridge_backend_normalizes_successful_bridge_reply() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Success(BridgeBrowserActionSuccess {
data: json!({ "text": "天气" }),
aom_snapshot: vec![json!({ "role": "textbox", "name": "百度一下" })],
timing: Timing {
queue_ms: 4,
exec_ms: 14,
},
}),
)]));
let backend = BridgeBrowserBackend::new(transport, test_policy());
let output = backend
.invoke(
Action::GetText,
json!({ "selector": "#content_left" }),
"www.baidu.com",
)
.unwrap();
assert_eq!(output.seq, 1);
assert!(output.success);
assert_eq!(output.data, json!({ "text": "天气" }));
assert_eq!(
output.aom_snapshot,
vec![json!({ "role": "textbox", "name": "百度一下" })]
);
assert_eq!(
output.timing,
Timing {
queue_ms: 4,
exec_ms: 14,
}
);
}
#[test]
fn bridge_backend_maps_bridge_failure_to_pipe_error() {
let transport = Arc::new(FakeBridgeTransport::new(vec![Ok(
BridgeBrowserActionReply::Error(BridgeBrowserActionError {
message: "selector not found".to_string(),
details: json!({ "selector": "#missing" }),
}),
)]));
let backend = BridgeBrowserBackend::new(transport, test_policy());
let error = backend
.invoke(
Action::Click,
json!({ "selector": "#missing" }),
"www.baidu.com",
)
.unwrap_err();
assert!(matches!(error, PipeError::Protocol(message) if message == "bridge action failed: selector not found"));
}

View File

@@ -0,0 +1,80 @@
use serde_json::{json, Value};
use sgclaw::browser::bridge_contract::{BridgeBrowserActionRequest, BridgeLifecycleCall};
#[test]
fn bridge_contract_names_match_documented_bridge_surface() {
let lifecycle_names = [
BridgeLifecycleCall::Connect.bridge_name(),
BridgeLifecycleCall::Start.bridge_name(),
BridgeLifecycleCall::Stop.bridge_name(),
BridgeLifecycleCall::SubmitTask.bridge_name(),
];
assert_eq!(
lifecycle_names,
[
"sgclawConnect",
"sgclawStart",
"sgclawStop",
"sgclawSubmitTask",
]
);
}
#[test]
fn bridge_contract_represents_browser_action_requests_without_ws_business_frames() {
let requests = vec![
BridgeBrowserActionRequest::new(
"navigate",
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
),
BridgeBrowserActionRequest::new(
"click",
json!({ "selector": "#submit" }),
"www.zhihu.com",
),
BridgeBrowserActionRequest::new(
"getText",
json!({ "selector": "#content" }),
"www.zhihu.com",
),
];
let serialized = serde_json::to_value(&requests).unwrap();
let entries = serialized.as_array().unwrap();
let actions = entries
.iter()
.map(|entry| entry["action"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(
serialized,
json!([
{
"action": "navigate",
"params": { "url": "https://www.baidu.com" },
"expected_domain": "www.baidu.com"
},
{
"action": "click",
"params": { "selector": "#submit" },
"expected_domain": "www.zhihu.com"
},
{
"action": "getText",
"params": { "selector": "#content" },
"expected_domain": "www.zhihu.com"
}
])
);
assert_eq!(actions, vec!["navigate", "click", "getText"]);
let first = entries.first().unwrap();
let object = first.as_object().unwrap();
assert_eq!(object.len(), 3);
assert!(object.contains_key("action"));
assert!(object.contains_key("params"));
assert!(object.contains_key("expected_domain"));
assert_eq!(first["expected_domain"], Value::String("www.baidu.com".to_string()));
}

View File

@@ -9,6 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use common::MockTransport;
use serde_json::json;
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
use sgclaw::security::MacPolicy;
@@ -67,6 +68,7 @@ return {
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
let mut args = HashMap::new();
args.insert("top_n".to_string(), "How many rows to extract".to_string());
@@ -77,7 +79,7 @@ return {
command: "scripts/extract_hotlist.js".to_string(),
args,
};
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, browser_tool)
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, backend)
.unwrap();
let result = tool

View File

@@ -106,6 +106,53 @@ fn browser_tool_exposes_privileged_surface_metadata_backed_by_mac_policy() {
);
}
#[test]
fn browser_tool_accepts_approved_local_dashboard_navigate_request() {
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
seq: 1,
success: true,
data: serde_json::json!({"navigated": true}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 20,
},
}]));
let tool = BrowserPipeTool::new(transport.clone(), test_policy(), vec![1, 2, 3, 4])
.with_response_timeout(Duration::from_secs(1));
let result = tool
.invoke(
Action::Navigate,
serde_json::json!({
"url": "file:///C:/tmp/zhihu-hotlist-screen.html",
"sgclaw_local_dashboard_open": {
"source": "compat.workflow_executor",
"kind": "zhihu_hotlist_screen",
"output_path": "C:/tmp/zhihu-hotlist-screen.html",
"presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
}
}),
"__sgclaw_local_dashboard__",
)
.unwrap();
let sent = transport.sent_messages();
assert!(result.success);
assert!(matches!(
&sent[0],
AgentMessage::Command {
action,
params,
security,
..
} if action == &Action::Navigate
&& security.expected_domain == "__sgclaw_local_dashboard__"
&& params["url"] == serde_json::json!("file:///C:/tmp/zhihu-hotlist-screen.html")
&& params["sgclaw_local_dashboard_open"]["kind"] == serde_json::json!("zhihu_hotlist_screen")
));
}
#[test]
fn default_rules_allow_zhihu_navigation() {
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -115,3 +162,22 @@ fn default_rules_allow_zhihu_navigation() {
policy.validate(&Action::Navigate, "www.zhihu.com").unwrap();
}
#[test]
fn mac_policy_rejects_non_html_local_dashboard_presentation() {
let rules_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("rules.json");
let policy = MacPolicy::load_from_path(rules_path).unwrap();
let err = policy
.validate_local_dashboard_presentation(
&Action::Navigate,
"__sgclaw_local_dashboard__",
"file:///C:/tmp/zhihu-hotlist-screen.txt",
"C:/tmp/zhihu-hotlist-screen.txt",
)
.unwrap_err();
assert!(err.to_string().contains("local dashboard"));
}

View File

@@ -0,0 +1,356 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{json, Value};
use sgclaw::browser::ws_backend::WsClient;
use sgclaw::browser::{BrowserBackend, WsBrowserBackend};
use sgclaw::pipe::{Action, PipeError};
use sgclaw::security::MacPolicy;
fn test_policy() -> MacPolicy {
MacPolicy::from_json_str(
r#"{
"version": "1.0",
"domains": { "allowed": ["www.baidu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": []
}
}"#,
)
.unwrap()
}
struct FakeWsClient {
incoming: Mutex<VecDeque<Result<String, PipeError>>>,
sent: Mutex<Vec<String>>,
}
impl FakeWsClient {
fn new(frames: Vec<Result<&str, PipeError>>) -> Self {
Self {
incoming: Mutex::new(
frames
.into_iter()
.map(|frame| frame.map(str::to_string))
.collect(),
),
sent: Mutex::new(Vec::new()),
}
}
fn sent_frames(&self) -> Vec<String> {
self.sent.lock().unwrap().clone()
}
}
impl WsClient for FakeWsClient {
fn send_text(&self, payload: &str) -> Result<(), PipeError> {
self.sent.lock().unwrap().push(payload.to_string());
Ok(())
}
fn recv_text_timeout(&self, _timeout: Duration) -> Result<String, PipeError> {
self.incoming
.lock()
.unwrap()
.pop_front()
.unwrap_or(Err(PipeError::Timeout))
}
}
#[test]
fn ws_backend_ignores_welcome_frame_before_zero_status() {
let client = Arc::new(FakeWsClient::new(vec![
Ok("Welcome! You are client #1"),
Ok("0"),
Ok(
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
),
]));
let backend = WsBrowserBackend::new(
client.clone(),
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let output = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap();
assert!(output.success);
let sent = client.sent_frames();
assert_eq!(sent.len(), 1);
}
#[test]
fn ws_backend_ignores_json_welcome_frame_before_zero_status() {
let client = Arc::new(FakeWsClient::new(vec![
Ok(r#"{"type":"welcome","client_id":17,"server_time":"2026-04-04T11:04:54"}"#),
Ok("0"),
Ok(
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
),
]));
let backend = WsBrowserBackend::new(
client.clone(),
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let output = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap();
assert!(output.success);
let sent = client.sent_frames();
assert_eq!(sent.len(), 1);
}
#[test]
fn ws_backend_fails_on_non_numeric_non_welcome_status_frame() {
let client = Arc::new(FakeWsClient::new(vec![Ok("not-a-status") ]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(
Action::Click,
json!({
"target_url": "https://www.baidu.com/current",
"selector": "#submit"
}),
"www.baidu.com",
)
.unwrap_err();
assert!(error.to_string().contains("invalid browser status frame: not-a-status"));
}
#[test]
fn ws_backend_returns_success_for_zero_without_callback() {
let client = Arc::new(FakeWsClient::new(vec![
Ok("0"),
Ok(
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
),
]));
let backend = WsBrowserBackend::new(
client.clone(),
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let output = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap();
assert_eq!(output.seq, 1);
assert!(output.success);
assert_eq!(output.data, json!({ "text": "" }));
assert!(output.aom_snapshot.is_empty());
let sent = client.sent_frames();
assert_eq!(sent.len(), 1);
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded"));
assert_eq!(payload[2], json!("https://www.baidu.com"));
}
#[test]
fn ws_backend_fails_immediately_on_non_zero_return_code() {
let client = Arc::new(FakeWsClient::new(vec![Ok("7")]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(
Action::Click,
json!({
"target_url": "https://www.baidu.com/current",
"selector": "#submit"
}),
"www.baidu.com",
)
.unwrap_err();
assert!(error.to_string().contains("browser returned non-zero status: 7"));
}
#[test]
fn ws_backend_waits_for_callback_and_normalizes_result_payload() {
let client = Arc::new(FakeWsClient::new(vec![
Ok("0"),
Ok(
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com/current@_@sgclaw_cb_1@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
),
]));
let backend = WsBrowserBackend::new(
client.clone(),
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let output = backend
.invoke(
Action::GetText,
json!({
"target_url": "https://www.baidu.com/current",
"selector": "#content"
}),
"www.baidu.com",
)
.unwrap();
assert_eq!(output.seq, 1);
assert!(output.success);
assert_eq!(output.data, json!({ "text": "天气" }));
assert!(output.aom_snapshot.is_empty());
let sent = client.sent_frames();
assert_eq!(sent.len(), 1);
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
assert_eq!(payload[1], json!("sgBrowserExcuteJsCodeByArea"));
}
#[test]
fn ws_backend_times_out_while_waiting_for_callback_after_zero_status() {
let client = Arc::new(FakeWsClient::new(vec![Ok("0")]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_millis(1));
let error = backend
.invoke(
Action::Eval,
json!({
"target_url": "https://www.baidu.com/current",
"script": "2 + 2"
}),
"www.baidu.com",
)
.unwrap_err();
assert!(matches!(error, PipeError::Timeout));
}
#[test]
fn ws_backend_times_out_when_navigate_callback_never_arrives() {
let client = Arc::new(FakeWsClient::new(vec![
Err(PipeError::Timeout),
Err(PipeError::Timeout),
]));
let backend = WsBrowserBackend::new(client.clone(), test_policy(), "https://www.zhihu.com")
.with_response_timeout(Duration::from_millis(1));
let error = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.zhihu.com/hot" }),
"www.baidu.com",
)
.unwrap_err();
assert!(matches!(error, PipeError::Timeout));
let sent = client.sent_frames();
let payload: Value = serde_json::from_str(&sent[0]).unwrap();
assert_eq!(payload[1], json!("sgHideBrowserCallAfterLoaded"));
assert_eq!(payload[2], json!("https://www.zhihu.com/hot"));
}
#[test]
fn ws_backend_reuses_last_navigated_url_for_followup_requests() {
let client = Arc::new(FakeWsClient::new(vec![
Ok("0"),
Ok(
r#"["https://www.baidu.com/current","callBackJsToCpp","https://www.baidu.com/current@_@https://www.baidu.com@_@sgclaw_cb_1@_@sgHideBrowserCallAfterLoaded@_@"]"#,
),
Ok("0"),
Ok(
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_2@_@sgBrowserExcuteJsCodeByArea@_@热榜文本"]"#,
),
]));
let backend = WsBrowserBackend::new(client.clone(), test_policy(), "about:blank")
.with_response_timeout(Duration::from_secs(1));
backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.zhihu.com/hot" }),
"www.baidu.com",
)
.unwrap();
let output = backend
.invoke(
Action::GetText,
json!({ "selector": "body" }),
"www.baidu.com",
)
.unwrap();
assert!(output.success);
assert_eq!(output.data, json!({ "text": "热榜文本" }));
let sent = client.sent_frames();
assert_eq!(sent.len(), 2);
let navigate_payload: Value = serde_json::from_str(&sent[0]).unwrap();
assert_eq!(navigate_payload[0], json!("about:blank"));
assert_eq!(navigate_payload[1], json!("sgHideBrowserCallAfterLoaded"));
assert_eq!(navigate_payload[2], json!("https://www.zhihu.com/hot"));
let followup_payload: Value = serde_json::from_str(&sent[1]).unwrap();
assert_eq!(followup_payload[0], json!("https://www.zhihu.com/hot"));
assert_eq!(followup_payload[1], json!("sgBrowserExcuteJsCodeByArea"));
assert_eq!(followup_payload[2], json!("https://www.zhihu.com/hot"));
assert_eq!(followup_payload[4], json!("hide"));
}
#[test]
fn ws_backend_propagates_socket_drop_after_navigate_send() {
let client = Arc::new(FakeWsClient::new(vec![Err(PipeError::PipeClosed)]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
)
.unwrap_err();
assert!(matches!(error, PipeError::PipeClosed));
}

View File

@@ -0,0 +1,422 @@
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tungstenite::{accept, Message};
#[path = "../src/browser/ws_probe.rs"]
mod ws_probe;
use ws_probe::{
parse_probe_args, run_probe_script, ProbeCliConfig, ProbeOutcome, ProbeStep, ProbeStepResult,
};
#[derive(Clone)]
enum ServerStep {
ReceiveThenReply { expected: String, reply: String },
ReceiveThenReplyFrames { expected: String, replies: Vec<String> },
ReceiveThenStaySilent { expected: String },
ReceiveThenClose { expected: String },
CloseBeforeReceive,
}
fn spawn_fake_server(script: Vec<ServerStep>) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let received = Arc::new(Mutex::new(Vec::new()));
let received_for_thread = received.clone();
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut socket = accept(stream).unwrap();
for step in script {
match step {
ServerStep::CloseBeforeReceive => {
socket.close(None).unwrap();
return;
}
ServerStep::ReceiveThenReply { expected, reply } => {
let message = socket.read().unwrap();
let payload = match message {
Message::Text(text) => text.to_string(),
other => panic!("expected text frame, got {other:?}"),
};
received_for_thread.lock().unwrap().push(payload.clone());
assert_eq!(payload, expected);
socket.send(Message::Text(reply.into())).unwrap();
}
ServerStep::ReceiveThenReplyFrames { expected, replies } => {
let message = socket.read().unwrap();
let payload = match message {
Message::Text(text) => text.to_string(),
other => panic!("expected text frame, got {other:?}"),
};
received_for_thread.lock().unwrap().push(payload.clone());
assert_eq!(payload, expected);
for reply in replies {
socket.send(Message::Text(reply.into())).unwrap();
}
}
ServerStep::ReceiveThenStaySilent { expected } => {
let message = socket.read().unwrap();
let payload = match message {
Message::Text(text) => text.to_string(),
other => panic!("expected text frame, got {other:?}"),
};
received_for_thread.lock().unwrap().push(payload.clone());
assert_eq!(payload, expected);
thread::sleep(Duration::from_millis(120));
}
ServerStep::ReceiveThenClose { expected } => {
let message = socket.read().unwrap();
let payload = match message {
Message::Text(text) => text.to_string(),
other => panic!("expected text frame, got {other:?}"),
};
received_for_thread.lock().unwrap().push(payload.clone());
assert_eq!(payload, expected);
socket.close(None).unwrap();
return;
}
}
}
});
(format!("ws://{addr}"), received, handle)
}
#[test]
fn parse_probe_args_rejects_non_ws_schemes() {
let cases = [
"wss://127.0.0.1:12345",
"http://127.0.0.1:12345",
"127.0.0.1:12345",
];
for ws_url in cases {
let args = vec![
"--ws-url".to_string(),
ws_url.to_string(),
"--timeout-ms".to_string(),
"1500".to_string(),
"--step".to_string(),
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
];
let err = parse_probe_args(&args).unwrap_err();
assert_eq!(
err.to_string(),
format!(
"probe argument error: unsupported --ws-url scheme (only ws:// is supported for this probe): {ws_url}"
)
);
}
}
#[test]
fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
let args = vec![
"--ws-url".to_string(),
"ws://127.0.0.1:12345".to_string(),
"--timeout-ms".to_string(),
"1500".to_string(),
"--step".to_string(),
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
"--step".to_string(),
"open-hot::[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
.to_string(),
];
let parsed = parse_probe_args(&args).unwrap();
assert_eq!(
parsed,
ProbeCliConfig {
ws_url: "ws://127.0.0.1:12345".to_string(),
timeout_ms: 1500,
steps: vec![
ProbeStep {
label: "open-agent".to_string(),
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
expect_reply: true,
},
ProbeStep {
label: "open-hot".to_string(),
payload:
"[\"about:blank\",\"sgBrowerserOpenPage\",\"https://www.zhihu.com/hot\"]"
.to_string(),
expect_reply: true,
},
],
}
);
}
#[test]
fn parse_probe_args_defaults_register_step_when_step_is_omitted() {
let args = vec![
"--ws-url".to_string(),
"ws://127.0.0.1:12345".to_string(),
];
let parsed = parse_probe_args(&args).unwrap();
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
assert_eq!(parsed.timeout_ms, 1500);
assert_eq!(
parsed.steps,
vec![ProbeStep {
label: "register".to_string(),
payload: r#"{"type":"register","role":"web"}"#.to_string(),
expect_reply: true,
}]
);
}
#[test]
fn parse_probe_args_defaults_timeout_when_flag_is_omitted() {
let args = vec![
"--ws-url".to_string(),
"ws://127.0.0.1:12345".to_string(),
"--step".to_string(),
"open-agent::[\"about:blank\",\"sgOpenAgent\"]".to_string(),
];
let parsed = parse_probe_args(&args).unwrap();
assert_eq!(parsed.ws_url, "ws://127.0.0.1:12345");
assert_eq!(parsed.timeout_ms, 1500);
assert_eq!(
parsed.steps,
vec![ProbeStep {
label: "open-agent".to_string(),
payload: "[\"about:blank\",\"sgOpenAgent\"]".to_string(),
expect_reply: true,
}]
);
}
#[test]
fn probe_records_welcome_then_silence_transcript() {
let steps = vec![
ProbeStep {
label: "open-agent".to_string(),
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
expect_reply: true,
},
ProbeStep {
label: "await-followup".to_string(),
payload: r#"["about:blank","sgNoop"]"#.to_string(),
expect_reply: true,
},
];
let (ws_url, received, handle) = spawn_fake_server(vec![
ServerStep::ReceiveThenReply {
expected: steps[0].payload.clone(),
reply: "Welcome! You are client #1".to_string(),
},
ServerStep::ReceiveThenStaySilent {
expected: steps[1].payload.clone(),
},
]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(
received.lock().unwrap().clone(),
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
);
assert_eq!(
results,
vec![
ProbeStepResult {
label: "open-agent".to_string(),
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
outcome: ProbeOutcome::Received(vec!["Welcome! You are client #1".to_string()]),
},
ProbeStepResult {
label: "await-followup".to_string(),
sent: r#"["about:blank","sgNoop"]"#.to_string(),
outcome: ProbeOutcome::TimedOut,
},
]
);
handle.join().unwrap();
}
#[test]
fn probe_runs_ordered_frame_script_and_records_per_step_results() {
let steps = vec![
ProbeStep {
label: "bootstrap-1".to_string(),
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
expect_reply: true,
},
ProbeStep {
label: "bootstrap-2".to_string(),
payload: r#"["about:blank","sgSetAuthInfo","probe-user","probe-token"]"#.to_string(),
expect_reply: true,
},
ProbeStep {
label: "action".to_string(),
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#.to_string(),
expect_reply: true,
},
];
let (ws_url, received, handle) = spawn_fake_server(vec![
ServerStep::ReceiveThenReply {
expected: steps[0].payload.clone(),
reply: "welcome".to_string(),
},
ServerStep::ReceiveThenReply {
expected: steps[1].payload.clone(),
reply: "0".to_string(),
},
ServerStep::ReceiveThenStaySilent {
expected: steps[2].payload.clone(),
},
]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(
received.lock().unwrap().clone(),
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
);
assert_eq!(results.len(), 3);
assert_eq!(results[0].label, "bootstrap-1");
assert_eq!(results[0].outcome, ProbeOutcome::Received(vec!["welcome".to_string()]));
assert_eq!(results[1].label, "bootstrap-2");
assert_eq!(results[1].outcome, ProbeOutcome::Received(vec!["0".to_string()]));
assert_eq!(results[2].label, "action");
assert_eq!(results[2].sent, r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#);
assert_eq!(results[2].outcome, ProbeOutcome::TimedOut);
handle.join().unwrap();
}
#[test]
fn probe_records_multiple_frames_for_one_step_within_timeout_window() {
let steps = vec![ProbeStep {
label: "bootstrap".to_string(),
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
expect_reply: true,
}];
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenReplyFrames {
expected: steps[0].payload.clone(),
replies: vec!["welcome".to_string(), "status:ready".to_string()],
}]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
results,
vec![ProbeStepResult {
label: "bootstrap".to_string(),
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
outcome: ProbeOutcome::Received(vec![
"welcome".to_string(),
"status:ready".to_string(),
]),
}]
);
handle.join().unwrap();
}
#[test]
fn probe_records_steps_that_do_not_wait_for_reply_without_ambiguity() {
let steps = vec![ProbeStep {
label: "fire-and-forget".to_string(),
payload: r#"["about:blank","sgNoop"]"#.to_string(),
expect_reply: false,
}];
let (ws_url, received, handle) =
spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
expected: steps[0].payload.clone(),
}]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
handle.join().unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
results,
vec![ProbeStepResult {
label: "fire-and-forget".to_string(),
sent: r#"["about:blank","sgNoop"]"#.to_string(),
outcome: ProbeOutcome::NoReplyExpected,
}]
);
}
#[test]
fn probe_records_close_when_server_closes_before_next_send() {
let steps = vec![
ProbeStep {
label: "open-agent".to_string(),
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
expect_reply: true,
},
ProbeStep {
label: "follow-up".to_string(),
payload: r#"["about:blank","sgNoop"]"#.to_string(),
expect_reply: true,
},
];
let (ws_url, received, handle) = spawn_fake_server(vec![
ServerStep::ReceiveThenReply {
expected: steps[0].payload.clone(),
reply: "welcome".to_string(),
},
ServerStep::CloseBeforeReceive,
]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
results,
vec![
ProbeStepResult {
label: "open-agent".to_string(),
sent: r#"["about:blank","sgOpenAgent"]"#.to_string(),
outcome: ProbeOutcome::Received(vec!["welcome".to_string()]),
},
ProbeStepResult {
label: "follow-up".to_string(),
sent: r#"["about:blank","sgNoop"]"#.to_string(),
outcome: ProbeOutcome::Closed,
},
]
);
handle.join().unwrap();
}
#[test]
fn probe_reports_socket_close_separately_from_timeout() {
let step = ProbeStep {
label: "close-case".to_string(),
payload: r#"["about:blank","sgOpenAgent"]"#.to_string(),
expect_reply: true,
};
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenClose {
expected: step.payload.clone(),
}]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), vec![step]).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [r#"["about:blank","sgOpenAgent"]"#]);
assert_eq!(results.len(), 1);
assert_eq!(results[0].label, "close-case");
assert_eq!(results[0].outcome, ProbeOutcome::Closed);
handle.join().unwrap();
}

View File

@@ -0,0 +1,195 @@
use serde_json::{json, Value};
use sgclaw::browser::ws_protocol::{decode_callback_frame, encode_v1_action};
use sgclaw::pipe::Action;
#[test]
fn encodes_navigate_frame_exactly_as_browser_array() {
let request = encode_v1_action(
&Action::Navigate,
&json!({ "url": "https://www.baidu.com" }),
"https://www.zhihu.com/hot",
Some("req42"),
)
.unwrap();
assert_eq!(
request.payload,
r#"["https://www.zhihu.com/hot","sgHideBrowserCallAfterLoaded","https://www.baidu.com","callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.baidu.com@_@sgclaw_cb_req42@_@sgHideBrowserCallAfterLoaded@_@\")"]"#
);
let callback = request.callback.unwrap();
assert_eq!(callback.request_id, "req42");
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
assert_eq!(callback.target_url, "https://www.baidu.com");
assert_eq!(callback.action_url, "sgHideBrowserCallAfterLoaded");
}
#[test]
fn encodes_get_text_frame_with_documented_callback_action_url() {
let request = encode_v1_action(
&Action::GetText,
&json!({
"target_url": "https://www.zhihu.com/hot",
"selector": "#content"
}),
"https://www.zhihu.com/hot",
Some("req42"),
)
.unwrap();
let payload: Value = serde_json::from_str(&request.payload).unwrap();
assert_eq!(
payload,
json!([
"https://www.zhihu.com/hot",
"sgBrowserExcuteJsCodeByArea",
"https://www.zhihu.com/hot",
"(function(){const el=document.querySelector(\"#content\");if(!el){throw new Error(\"selector not found: #content\");}const text=el.innerText ?? el.textContent ?? \"\";callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@\"+String(text));})();",
"hide"
])
);
let callback = request.callback.unwrap();
assert_eq!(callback.request_id, "req42");
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
}
#[test]
fn decodes_callback_payload_from_browser_frame() {
let callback = decode_callback_frame(
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req42@_@sgBrowserExcuteJsCodeByArea@_@天气"]"#,
)
.unwrap();
assert_eq!(callback.source_url, "https://www.zhihu.com/hot");
assert_eq!(callback.target_url, "https://www.zhihu.com/hot");
assert_eq!(callback.callback_name, "sgclaw_cb_req42");
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
assert_eq!(callback.response_text, "天气");
}
#[test]
fn rejects_malformed_callback_frames_and_missing_request_ids() {
let malformed = decode_callback_frame(
r#"["https://www.zhihu.com/hot","callBackJsToCpp","https://www.zhihu.com/hot@_@too-short"]"#,
)
.unwrap_err();
assert!(malformed.to_string().contains("malformed callback payload"));
let wrong_function = decode_callback_frame(
r#"["https://www.zhihu.com/hot","sgBrowerserOpenPage","0"]"#,
)
.unwrap_err();
assert!(wrong_function
.to_string()
.contains("callback frame must target callBackJsToCpp"));
let missing_request_id = encode_v1_action(
&Action::Eval,
&json!({
"target_url": "https://www.zhihu.com/hot",
"script": "2 + 2"
}),
"https://www.zhihu.com/hot",
None,
)
.unwrap_err();
assert!(missing_request_id
.to_string()
.contains("request_id is required"));
}
#[test]
fn eval_uses_documented_js_opcode_for_callback_action_url() {
let request = encode_v1_action(
&Action::Eval,
&json!({
"target_url": "https://www.zhihu.com/hot",
"script": "2 + 2"
}),
"https://www.zhihu.com/hot",
Some("req-eval"),
)
.unwrap();
let callback = request.callback.unwrap();
assert_eq!(callback.callback_name, "sgclaw_cb_req-eval");
assert_eq!(callback.action_url, "sgBrowserExcuteJsCodeByArea");
let payload: Value = serde_json::from_str(&request.payload).unwrap();
let js = payload[3].as_str().unwrap();
assert!(js.contains("callBackJsToCpp(\"https://www.zhihu.com/hot@_@https://www.zhihu.com/hot@_@sgclaw_cb_req-eval@_@sgBrowserExcuteJsCodeByArea@_@\"+String(result))"));
}
#[test]
fn covers_supported_v1_action_mapping_and_rejects_unsupported_actions() {
let cases = vec![
(
Action::Navigate,
json!({ "url": "https://www.baidu.com" }),
Some("req-nav"),
"sgHideBrowserCallAfterLoaded",
true,
),
(
Action::Click,
json!({
"target_url": "https://www.zhihu.com/hot",
"selector": "#submit"
}),
None,
"sgBrowserExcuteJsCodeByArea",
false,
),
(
Action::Type,
json!({
"target_url": "https://www.zhihu.com/hot",
"selector": "#kw",
"text": "天气"
}),
None,
"sgBrowserExcuteJsCodeByArea",
false,
),
(
Action::GetText,
json!({
"target_url": "https://www.zhihu.com/hot",
"selector": "#content"
}),
Some("req-get-text"),
"sgBrowserExcuteJsCodeByArea",
true,
),
(
Action::Eval,
json!({
"target_url": "https://www.zhihu.com/hot",
"script": "2 + 2"
}),
Some("req-eval"),
"sgBrowserExcuteJsCodeByArea",
true,
),
];
for (action, params, request_id, browser_function, expects_callback) in cases {
let request = encode_v1_action(&action, &params, "https://www.zhihu.com/hot", request_id)
.unwrap();
let payload: Value = serde_json::from_str(&request.payload).unwrap();
assert_eq!(payload[1], json!(browser_function), "action={action:?}");
assert_eq!(request.callback.is_some(), expects_callback, "action={action:?}");
}
let unsupported = encode_v1_action(
&Action::GetHtml,
&json!({ "selector": "body" }),
"https://www.zhihu.com/hot",
None,
)
.unwrap_err();
assert!(unsupported.to_string().contains("unsupported browser ws action"));
}

View File

@@ -17,6 +17,7 @@ impl MockTransport {
}
}
#[allow(dead_code)]
pub fn sent_messages(&self) -> Vec<AgentMessage> {
self.sent.lock().unwrap().clone()
}

View File

@@ -7,6 +7,7 @@ use common::MockTransport;
use serde_json::{json, Value};
use sgclaw::security::MacPolicy;
use sgclaw::{
browser::{BrowserBackend, PipeBrowserBackend},
compat::browser_tool_adapter::ZeroClawBrowserTool,
pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, ExecutionSurfaceKind, Timing},
};
@@ -28,7 +29,7 @@ fn test_policy() -> MacPolicy {
fn build_adapter(
messages: Vec<BrowserMessage>,
) -> (Arc<MockTransport>, ZeroClawBrowserTool<MockTransport>) {
) -> (Arc<MockTransport>, ZeroClawBrowserTool) {
let transport = Arc::new(MockTransport::new(messages));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
@@ -36,8 +37,9 @@ fn build_adapter(
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
(transport, ZeroClawBrowserTool::new(browser_tool))
(transport, ZeroClawBrowserTool::new(backend))
}
#[test]

View File

@@ -191,6 +191,60 @@ fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
}
#[test]
fn sgclaw_settings_load_browser_ws_url_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-browser-ws-config-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"apiKey": "sk-runtime",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"browserWsUrl": "ws://127.0.0.1:12345"
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
assert_eq!(
settings.browser_ws_url.as_deref(),
Some("ws://127.0.0.1:12345")
);
}
#[test]
fn sgclaw_settings_load_service_ws_listen_addr_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-service-ws-config-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
fs::write(
&config_path,
r#"{
"apiKey": "sk-runtime",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"serviceWsListenAddr": "127.0.0.1:42321"
}"#,
)
.unwrap();
let settings = SgClawSettings::load(Some(config_path.as_path()))
.unwrap()
.expect("expected sgclaw settings from config file");
assert_eq!(
settings.service_ws_listen_addr.as_deref(),
Some("127.0.0.1:42321")
);
}
#[test]
fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
let settings = SgClawSettings::from_legacy_deepseek_fields(

View File

@@ -33,7 +33,8 @@ async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
assert!(result.success, "{result:?}");
assert!(output_path.exists());
assert!(result.output.contains(output_path.to_str().unwrap()));
let payload: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(payload["output_path"], json!(output_path.to_str().unwrap()));
let unzip = ProcessCommand::new("unzip")
.args([

View File

@@ -13,6 +13,7 @@ use serde_json::{json, Value};
use sgclaw::agent::{
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
};
use sgclaw::compat::workflow_executor::finalize_screen_export;
use sgclaw::compat::runtime::{execute_task, execute_task_with_sgclaw_settings, CompatTaskContext};
use sgclaw::config::{DeepSeekSettings, SgClawSettings};
use sgclaw::pipe::{
@@ -176,6 +177,7 @@ fn start_fake_deepseek_server(
Err(err) => panic!("failed to accept provider request: {err}"),
}
};
stream.set_nonblocking(false).unwrap();
let body = read_http_json_body(&mut stream);
request_log.lock().unwrap().push(body);
@@ -1861,6 +1863,15 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
let request_bodies = requests.lock().unwrap().clone();
let first_request = request_bodies[0].to_string();
let tool_names = request_tool_names(&request_bodies[0]);
let loaded_skills_message = sent
.iter()
.find_map(|message| match message {
AgentMessage::LogEntry { level, message } if level == "info" && message.starts_with("loaded skills: ") => {
Some(message.clone())
}
_ => None,
})
.expect("expected loaded skills log entry");
assert!(sent.iter().any(|message| {
matches!(
@@ -1869,15 +1880,11 @@ fn handle_browser_message_exposes_real_zhihu_skill_lib_to_provider_request() {
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!(loaded_skills_message.contains("office-export-xlsx@0.1.0"));
assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0"));
assert!(loaded_skills_message.contains("zhihu-hotlist-screen@0.1.0"));
assert!(loaded_skills_message.contains("zhihu-navigate@0.1.0"));
assert!(loaded_skills_message.contains("zhihu-write@0.1.0"));
assert_eq!(request_bodies.len(), 1);
assert!(first_request.contains("office-export-xlsx"));
assert!(first_request.contains("zhihu-hotlist"));
@@ -2107,145 +2114,9 @@ fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() {
}
#[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() {
fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
let workspace_root = temp_workspace_root();
let config_path = write_deepseek_config_with_skills_dir(
@@ -2282,6 +2153,118 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
)
.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();
let sent = transport.sent_messages();
let summary = task_complete_summary(&sent);
let generated = extract_generated_artifact_path(&summary, ".xlsx");
assert!(summary.contains("已导出并打开知乎热榜 Excel"));
assert!(summary.contains(".xlsx"));
assert!(generated.exists());
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::LogEntry { level, message }
if level == "info" && message == "call openxml_office"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::Eval
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, params, .. }
if action == &Action::Navigate && params.get("sgclaw_local_dashboard_open").is_some()
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
}
#[test]
fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open() {
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![
success_browser_response(1, json!({ "navigated": true })),
success_browser_response(
2,
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
),
success_browser_response(
3,
json!({
"text": {
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
}
}),
),
success_browser_response(4, 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,
@@ -2299,10 +2282,43 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
let sent = transport.sent_messages();
let summary = task_complete_summary(&sent);
let generated = extract_generated_artifact_path(&summary, ".html");
let navigate = sent
.iter()
.find_map(|message| match message {
AgentMessage::Command {
action,
params,
security,
..
} if action == &Action::Navigate
&& security.expected_domain == "__sgclaw_local_dashboard__" => Some((params, security)),
_ => None,
})
.expect("dashboard route should emit local-dashboard navigate request");
assert!(summary.contains("生成知乎热榜大屏"));
assert!(summary.contains("在浏览器中打开知乎热榜大屏"));
assert!(summary.contains(".html"));
assert!(generated.exists());
assert_eq!(
navigate.0["sgclaw_local_dashboard_open"]["output_path"].as_str(),
generated.to_str()
);
assert!(navigate.0["url"]
.as_str()
.expect("dashboard open url should be present")
.starts_with("file://"));
assert_eq!(
navigate.0["sgclaw_local_dashboard_open"]["source"],
json!("compat.workflow_executor")
);
assert_eq!(
navigate.0["sgclaw_local_dashboard_open"]["kind"],
json!("zhihu_hotlist_screen")
);
assert_eq!(
navigate.0["sgclaw_local_dashboard_open"]["presentation_url"],
navigate.0["url"]
);
assert!(sent.iter().any(|message| {
matches!(
message,
@@ -2330,6 +2346,13 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
AgentMessage::Command { action, .. } if action == &Action::Eval
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "info" && message == "call openxml_office"
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
@@ -2339,9 +2362,55 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_tool() {
}));
}
#[test]
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
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 browser_backend = sgclaw::browser::PipeBrowserBackend::from_inner(browser_tool);
let workspace_root = temp_workspace_root();
let output_path = workspace_root.join("zhihu-hotlist-screen.html");
fs::write(&output_path, "<html><body>fixture</body></html>").unwrap();
let payload = json!({
"title": "知乎热榜大屏",
"output_path": output_path,
"renderer": "screen_html_export",
"row_count": 2,
"snapshot_id": "snapshot-test",
"presentation": {
"mode": "new_tab",
"title": "知乎热榜大屏",
"open_in_new_tab": true
}
});
let summary = finalize_screen_export(&browser_backend, &payload.to_string()).unwrap();
assert!(summary.contains("已生成知乎热榜大屏"));
assert!(summary.contains(output_path.to_string_lossy().as_ref()));
assert!(summary.contains("但浏览器自动打开失败screen_html_export did not return presentation.url"));
let sent = transport.sent_messages();
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, params, .. }
if action == &Action::Navigate && params.get("sgclaw_local_dashboard_open").is_some()
)
}));
}
#[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());
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
let workspace_root = temp_workspace_root();
let config_path = write_deepseek_config_with_skills_dir(
@@ -2416,6 +2485,7 @@ fn handle_browser_message_runs_zhihu_hotlist_export_via_zeroclaw_primary_orchest
if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
}
#[test]
@@ -2527,6 +2597,143 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p
}));
}
#[test]
fn zhihu_generated_auto_publish_matches_primary_orchestration_gate() {
assert!(
sgclaw::compat::orchestration::should_use_primary_orchestration(
"在知乎自动发表一篇名称为人工智能技能大全",
Some("https://www.zhihu.com/"),
Some("知乎"),
)
);
}
#[test]
fn zhihu_hotlist_export_route_stays_ahead_of_generated_article_publish() {
use sgclaw::compat::workflow_executor::{detect_route, WorkflowRoute};
assert_eq!(
detect_route(
"打开知乎热榜获取前10条数据并导出 Excel",
Some("https://www.zhihu.com/"),
Some("知乎")
),
Some(WorkflowRoute::ZhihuHotlistExportXlsx)
);
}
#[test]
fn zhihu_generated_auto_publish_uses_provider_and_submits_publish_without_confirmation() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let response = json!({
"choices": [{
"message": {
"content": "标题:人工智能技能大全\n正文:第一段内容。\n\n第二段内容。"
}
}]
});
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![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!({ "navigated": true })),
success_browser_response(
2,
json!({
"text": {
"status": "creator_entry_clicked",
"current_url": "https://www.zhihu.com/creator",
"next_url": "https://zhuanlan.zhihu.com/write"
}
}),
),
success_browser_response(3, json!({ "navigated": true })),
success_browser_response(
4,
json!({
"text": {
"status": "editor_ready",
"current_url": "https://zhuanlan.zhihu.com/write"
}
}),
),
success_browser_response(
5,
json!({
"text": {
"status": "publish_submitted",
"current_url": "https://zhuanlan.zhihu.com/write",
"title": "人工智能技能大全"
}
}),
),
]));
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();
assert_eq!(request_bodies.len(), 1);
assert!(request_bodies[0].to_string().contains("人工智能技能大全"));
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-write.fill_article_draft"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::Navigate
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if *success && summary.contains("确认发布")
)
}));
}
#[test]
fn zhihu_publish_task_matches_primary_orchestration_gate() {
assert!(
@@ -3078,71 +3285,37 @@ fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing
}
#[test]
fn browser_orchestration_registers_superrpa_tools_natively() {
fn browser_orchestration_executes_hotlist_export_natively_from_hotlist_page() {
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]);
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
let workspace_root = temp_workspace_root();
let config_path = write_deepseek_config_with_skills_dir(
&workspace_root,
"deepseek-test-key",
&base_url,
"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![success_browser_response(
1,
json!({ "text": "知乎热榜\n1\n问题一\n344万热度" }),
)]));
let transport = Arc::new(MockTransport::new(vec![
success_browser_response(
1,
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
),
success_browser_response(
2,
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(),
@@ -3164,22 +3337,60 @@ fn browser_orchestration_registers_superrpa_tools_natively() {
)
.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]);
let summary = task_complete_summary(&sent);
let generated = extract_generated_artifact_path(&summary, ".xlsx");
assert!(first_request.contains("superrpa_browser"));
assert!(tool_names.contains(&"superrpa_browser".to_string()));
assert!(tool_names.contains(&"openxml_office".to_string()));
assert!(summary.contains(".xlsx"));
assert!(generated.exists());
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::LogEntry { level, message }
if level == "info" && message == "call openxml_office"
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::GetText
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::Eval
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command { action, .. } if action == &Action::Navigate
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" &&
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
}
#[test]
@@ -3240,88 +3451,13 @@ fn zhihu_export_does_not_use_frontend_owned_mainline() {
#[test]
fn browser_skill_usage_is_execution_not_prompt_only() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
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,
"http://127.0.0.1:9",
"deepseek-chat",
Some(real_skill_lib_root().to_str().unwrap()),
);
@@ -3331,7 +3467,18 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
success_browser_response(1, json!({ "navigated": true })),
success_browser_response(
2,
json!({ "text": "知乎热榜\n1\n问题一\n344万热度\n2\n问题二\n266万热度" }),
json!({ "text": "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度" }),
),
success_browser_response(
3,
json!({
"text": {
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
}
}),
),
]));
let browser_tool = BrowserPipeTool::new(
@@ -3354,15 +3501,13 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
},
)
.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();
let summary = task_complete_summary(&sent);
let generated = extract_generated_artifact_path(&summary, ".xlsx");
assert!(summary.contains(".xlsx"));
assert!(generated.exists());
assert!(sent.iter().any(|message| {
matches!(
message,
@@ -3370,6 +3515,29 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
if *success && 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 openxml_office"
)
}));
assert!(!sent.iter().any(|message| {
matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" &&
(message == "compat_llm_primary" || message == "compat_skill_runner_primary")
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
assert!(!sent.iter().any(|message| {
matches!(
message,
@@ -3387,7 +3555,6 @@ fn browser_skill_usage_is_execution_not_prompt_only() {
message == "getText ol li")
)
}));
assert!(!first_request.contains("Preloaded skill context:"));
}
#[test]

View File

@@ -47,4 +47,13 @@ async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contra
assert!(html.contains("问题一"));
assert!(html.contains("344万"));
assert!(html.contains("const defaultPayload ="));
assert!(html.contains("汇报摘要"));
assert!(html.contains("fitScreenToViewport"));
assert!(html.contains("dashboard-canvas"));
assert!(html.contains("themeSwitcher"));
assert!(html.contains("gov_blue_gold"));
assert!(html.contains("tech_cyan_blue"));
assert!(html.contains("industry_ink_green"));
assert!(html.contains("meeting_red_gold"));
assert!(html.contains("localStorage.setItem(\"zhihu-hotlist-theme\""));
}

View File

@@ -21,6 +21,24 @@ fn browser_init_round_trip_uses_frozen_wire_format() {
assert_eq!(serde_json::to_string(&message).unwrap(), raw);
}
#[test]
fn browser_lifecycle_messages_use_frozen_wire_tags() {
let connect_raw = r#"{"type":"connect"}"#;
let start_raw = r#"{"type":"start"}"#;
let stop_raw = r#"{"type":"stop"}"#;
let connect: BrowserMessage = serde_json::from_str(connect_raw).unwrap();
let start: BrowserMessage = serde_json::from_str(start_raw).unwrap();
let stop: BrowserMessage = serde_json::from_str(stop_raw).unwrap();
assert_eq!(connect, BrowserMessage::Connect);
assert_eq!(start, BrowserMessage::Start);
assert_eq!(stop, BrowserMessage::Stop);
assert_eq!(serde_json::to_string(&connect).unwrap(), connect_raw);
assert_eq!(serde_json::to_string(&start).unwrap(), start_raw);
assert_eq!(serde_json::to_string(&stop).unwrap(), stop_raw);
}
#[test]
fn command_serializes_action_and_security_fields() {
let message = AgentMessage::Command {
@@ -40,6 +58,16 @@ fn command_serializes_action_and_security_fields() {
assert!(raw.contains(r#""expected_domain":"oa.example.com""#));
}
#[test]
fn agent_status_changed_serializes_with_expected_tag() {
let raw = serde_json::to_string(&AgentMessage::StatusChanged {
state: "started".to_string(),
})
.unwrap();
assert_eq!(raw, r#"{"type":"status_changed","state":"started"}"#);
}
#[test]
fn response_deserializes_timing_and_payload() {
let raw = r#"{"type":"response","seq":7,"success":true,"data":{"text":"提交成功"},"aom_snapshot":[],"timing":{"queue_ms":2,"exec_ms":38}}"#;

View File

@@ -1,132 +0,0 @@
use serde_json::json;
use sgclaw::agent::planner::{build_execution_preview, plan_instruction, PlannerError};
use sgclaw::config::PlannerMode;
use sgclaw::pipe::Action;
#[test]
fn planner_module_is_explicitly_legacy_dev_only() {
assert!(sgclaw::agent::planner::LEGACY_DEV_ONLY);
}
#[test]
fn planner_converts_baidu_search_instruction_into_three_steps() {
let plan = plan_instruction("打开百度搜索天气").unwrap();
assert_eq!(plan.summary, "已在百度搜索天气");
assert_eq!(plan.steps.len(), 3);
assert_eq!(plan.steps[0].action, Action::Navigate);
assert_eq!(
plan.steps[0].params,
json!({ "url": "https://www.baidu.com" })
);
assert_eq!(plan.steps[1].action, Action::Type);
assert_eq!(
plan.steps[1].params,
json!({ "selector": "#kw", "text": "天气", "clear_first": true })
);
assert_eq!(plan.steps[2].action, Action::Click);
assert_eq!(plan.steps[2].params, json!({ "selector": "#su" }));
}
#[test]
fn planner_supports_baidu_search_variant_with_conjunction() {
let plan = plan_instruction("打开百度并搜索电网调度").unwrap();
assert_eq!(plan.summary, "已在百度搜索电网调度");
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_supports_open_zhihu_homepage_instruction() {
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" })
);
assert_eq!(plan.steps[0].expected_domain, "www.zhihu.com");
assert_eq!(plan.steps[0].log_message, "navigate https://www.zhihu.com");
}
#[test]
fn planner_supports_open_baidu_homepage_instruction() {
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.baidu.com" })
);
assert_eq!(plan.steps[0].expected_domain, "www.baidu.com");
assert_eq!(plan.steps[0].log_message, "navigate https://www.baidu.com");
}
#[test]
fn planner_rejects_unrelated_instruction() {
let err = plan_instruction("打开谷歌搜索天气").unwrap_err();
assert_eq!(
err,
PlannerError::UnsupportedInstruction("打开谷歌搜索天气".to_string())
);
}
#[test]
fn plan_first_mode_builds_visible_preview_for_zhihu_excel_flow() {
let preview = build_execution_preview(
PlannerMode::ZeroclawPlanFirst,
"读取知乎热榜数据,并导出 excel 文件",
Some("https://www.zhihu.com/hot"),
Some("知乎热榜"),
)
.expect("expected plan preview");
assert_eq!(preview.summary, "先规划再执行知乎热榜 Excel 导出");
assert!(preview
.steps
.iter()
.any(|step| step.contains("navigate https://www.zhihu.com/hot")));
assert!(preview
.steps
.iter()
.any(|step| step.contains("getText main")));
assert!(preview
.steps
.iter()
.any(|step| step.contains("call openxml_office")));
}
#[test]
fn legacy_planner_mode_skips_runtime_preview() {
let preview = build_execution_preview(
PlannerMode::LegacyDeterministic,
"打开百度搜索天气",
None,
None,
);
assert!(preview.is_none());
}

View File

@@ -0,0 +1,922 @@
use std::io::{BufRead, BufReader, Read as _, Write};
use std::net::TcpListener;
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use reqwest::blocking::Client;
use serde_json::{json, Value};
use sgclaw::service::{ClientMessage, ServiceMessage};
use tungstenite::{accept, Message};
const RUNTIME_DROP_PANIC_TEXT: &str =
"Cannot drop a runtime in a context where blocking is not allowed";
fn read_ws_text(stream: &mut tungstenite::WebSocket<std::net::TcpStream>) -> String {
match stream.read().unwrap() {
Message::Text(text) => text.to_string(),
other => panic!("expected text frame, got {other:?}"),
}
}
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}"),
}
};
stream.set_nonblocking(false).unwrap();
let body = match read_http_json_body(&mut stream) {
Ok(body) => body,
Err(_) => continue,
};
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 std::io::Read) -> Result<Value, &'static str> {
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();
if bytes == 0 {
return Err("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 Some(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())
}) else {
return Err("missing content-length header");
};
while buffer.len() < headers_end + content_length {
let mut chunk = vec![0_u8; content_length];
let bytes = stream.read(&mut chunk).unwrap();
if bytes == 0 {
return Err("unexpected EOF while reading body");
}
buffer.extend_from_slice(&chunk[..bytes]);
}
Ok(serde_json::from_slice(&buffer[headers_end..headers_end + content_length]).unwrap())
}
#[derive(Debug)]
enum CallbackHostBrowserEvent {
BrowserFrame(Value),
CommandEnvelope(Value),
}
fn start_callback_host_hotlist_browser_server(
event_tx: mpsc::Sender<CallbackHostBrowserEvent>,
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let mut websocket = accept(stream).unwrap();
let register = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected register frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(register))
.unwrap();
websocket
.send(Message::Text(
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
.to_string()
.into(),
))
.unwrap();
let first_action = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(first_action.clone()))
.unwrap();
let Some(values) = first_action.as_array() else {
websocket.close(None).ok();
return;
};
let is_helper_open = values.len() >= 3
&& values[1] == json!("sgBrowerserOpenPage")
&& values[2]
.as_str()
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"));
if !is_helper_open {
websocket.close(None).ok();
return;
}
let helper_url = values[2].as_str().unwrap().to_string();
let helper_origin = helper_url
.trim_end_matches("/sgclaw/browser-helper.html")
.to_string();
let helper_client = Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
let helper_html = helper_client
.get(&helper_url)
.send()
.unwrap()
.error_for_status()
.unwrap()
.text()
.unwrap();
assert!(helper_html.contains("sgclawReady"));
assert!(helper_html.contains("sgclawOnLoaded"));
assert!(helper_html.contains("sgclawOnGetText"));
assert!(helper_html.contains("sgclawOnEval"));
let pre_ready_command: Value = helper_client
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
.send()
.unwrap()
.error_for_status()
.unwrap()
.json()
.unwrap();
event_tx
.send(CallbackHostBrowserEvent::CommandEnvelope(pre_ready_command))
.unwrap();
helper_client
.post(format!("{helper_origin}/sgclaw/callback/ready"))
.json(&json!({
"type": "ready",
"helper_url": helper_url,
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
let hotlist_text = "知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度";
let hotlist_payload = json!({
"source": "https://www.zhihu.com/hot",
"sheet_name": "知乎热榜",
"columns": ["rank", "title", "heat"],
"rows": [[1, "问题一", "344万"], [2, "问题二", "266万"]]
})
.to_string();
let deadline = Instant::now() + Duration::from_secs(10);
let mut saw_get_text = false;
let mut saw_eval = false;
while Instant::now() < deadline {
let envelope: Value = helper_client
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
.send()
.unwrap()
.error_for_status()
.unwrap()
.json()
.unwrap();
let Some(command) = envelope.get("command").and_then(Value::as_object) else {
thread::sleep(Duration::from_millis(20));
continue;
};
event_tx
.send(CallbackHostBrowserEvent::CommandEnvelope(envelope.clone()))
.unwrap();
let action_name = command
.get("action")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
helper_client
.post(format!("{helper_origin}/sgclaw/callback/commands/ack"))
.json(&json!({ "type": "command_ack" }))
.send()
.unwrap()
.error_for_status()
.unwrap();
let args = command
.get("args")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
match action_name.as_str() {
"sgBrowerserOpenPage" => {}
"sgBrowserExcuteJsCodeByDomain" => {
let script = args.get(1).and_then(Value::as_str).unwrap_or_default();
if script.contains("sgclawOnGetText") {
saw_get_text = true;
helper_client
.post(format!("{helper_origin}/sgclaw/callback/events"))
.json(&json!({
"callback": "sgclawOnGetText",
"request_url": helper_url,
"target_url": "https://www.zhihu.com/hot",
"action": action_name,
"payload": { "text": hotlist_text }
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
} else if script.contains("sgclawOnEval") {
saw_eval = true;
helper_client
.post(format!("{helper_origin}/sgclaw/callback/events"))
.json(&json!({
"callback": "sgclawOnEval",
"request_url": helper_url,
"target_url": "https://www.zhihu.com/hot",
"action": action_name,
"payload": { "value": hotlist_payload }
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
break;
} else {
panic!("unexpected callback-host domain command: {script}");
}
}
other => panic!("unexpected callback-host command action {other}"),
}
}
assert!(saw_get_text, "expected callback-host getText command");
assert!(saw_eval, "expected callback-host eval command");
websocket.close(None).ok();
});
(format!("ws://{address}"), handle)
}
fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
let frames_for_thread = Arc::clone(&frames);
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
let mut socket = accept(stream).unwrap();
let mut action_count = 0_u64;
loop {
let message = match socket.read() {
Ok(message) => message,
Err(tungstenite::Error::ConnectionClosed)
| Err(tungstenite::Error::AlreadyClosed) => break,
Err(err) => panic!("browser ws test server read failed: {err}"),
};
let payload = match message {
Message::Text(text) => text.to_string(),
Message::Ping(payload) => {
socket.send(Message::Pong(payload)).unwrap();
continue;
}
Message::Close(_) => break,
other => panic!("expected text frame, got {other:?}"),
};
frames_for_thread.lock().unwrap().push(payload.clone());
let parsed: Value = serde_json::from_str(&payload).unwrap();
if parsed.get("type").and_then(Value::as_str) == Some("register") {
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
socket
.send(Message::Text(
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
.to_string()
.into(),
))
.unwrap();
socket.send(Message::Text("0".into())).unwrap();
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgHideBrowserCallAfterLoaded@_@"
)
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
r#"{"source":"https://www.zhihu.com/hot","sheet_name":"知乎热榜","columns":["rank","title","heat"],"rows":[[1,"问题一","344万"],[2,"问题二","266万"]]}"#.to_string()
};
json!([
request_url,
"callBackJsToCpp",
format!(
"{request_url}@_@{target_url}@_@sgclaw_cb_{action_count}@_@sgBrowserExcuteJsCodeByArea@_@{response_text}"
)
])
}
other => panic!("unexpected browser action {other}"),
};
socket
.send(Message::Text(callback_frame.to_string().into()))
.unwrap();
if action_count >= 3 {
break;
}
}
socket.close(None).ok();
});
(format!("ws://{address}"), frames, handle)
}
#[test]
fn client_submits_first_user_line_to_service() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::TaskComplete {
success: true,
summary: "done".to_string(),
})
.unwrap()
.into(),
))
.unwrap();
websocket.close(None).unwrap();
request
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all("打开百度搜索天气\n".as_bytes())
.unwrap();
let status = child.wait().unwrap();
assert!(status.success());
let request = server.join().unwrap();
assert_eq!(
request,
ClientMessage::SubmitTask {
instruction: "打开百度搜索天气".to_string(),
conversation_id: "".to_string(),
messages: vec![],
page_url: "".to_string(),
page_title: "".to_string(),
}
);
}
#[test]
fn client_sends_connect_request_and_exits_after_status() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::StatusChanged {
state: "connected".to_string(),
})
.unwrap()
.into(),
))
.unwrap();
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::StatusChanged {
state: "connected again".to_string(),
})
.unwrap()
.into(),
))
.unwrap();
websocket.close(None).unwrap();
request
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all("/connect\n".as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
let request = server.join().unwrap();
assert!(output.status.success());
assert_eq!(request, ClientMessage::Connect);
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["status: connected"]);
}
#[test]
fn client_sends_start_and_stop_requests_with_explicit_commands() {
for (input, expected_request, expected_status) in [
("/start\n", ClientMessage::Start, "status: started"),
("/stop\n", ClientMessage::Stop, "status: stopped"),
] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let expected_state = expected_status.trim_start_matches("status: ").to_string();
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::StatusChanged {
state: expected_state,
})
.unwrap()
.into(),
))
.unwrap();
websocket.close(None).unwrap();
request
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
let request = server.join().unwrap();
assert!(output.status.success());
assert_eq!(request, expected_request);
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec![expected_status]);
}
}
#[test]
fn client_prints_completion_only_once() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::TaskComplete {
success: true,
summary: "done".to_string(),
})
.unwrap()
.into(),
))
.unwrap();
websocket
.send(Message::Text(
serde_json::to_string(&ServiceMessage::TaskComplete {
success: true,
summary: "done again".to_string(),
})
.unwrap()
.into(),
))
.unwrap();
websocket.close(None).unwrap();
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all("打开百度搜索天气\n".as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
server.join().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["done"]);
}
#[test]
fn client_prints_log_entries_in_order_before_completion() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
for message in [
ServiceMessage::LogEntry {
level: "info".to_string(),
message: "step 1".to_string(),
},
ServiceMessage::LogEntry {
level: "info".to_string(),
message: "step 2".to_string(),
},
ServiceMessage::TaskComplete {
success: true,
summary: "done".to_string(),
},
] {
websocket
.send(Message::Text(serde_json::to_string(&message).unwrap().into()))
.unwrap();
}
websocket.close(None).unwrap();
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all("打开百度搜索天气\n".as_bytes())
.unwrap();
let stdout = child.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
let reader = thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
tx.send(line.unwrap()).unwrap();
}
});
let first = rx.recv_timeout(Duration::from_secs(1)).unwrap();
let second = rx.recv_timeout(Duration::from_secs(1)).unwrap();
let third = rx.recv_timeout(Duration::from_secs(1)).unwrap();
let status = child.wait().unwrap();
reader.join().unwrap();
server.join().unwrap();
assert!(status.success());
assert_eq!(vec![first, second, third], vec!["step 1", "step 2", "done"]);
}
#[test]
fn client_exits_with_failure_when_service_disconnects_before_completion() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let ws_url = format!("ws://{address}");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
websocket.close(None).unwrap();
request
});
let mut child = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all("打开百度搜索天气\n".as_bytes())
.unwrap();
let status = child.wait().unwrap();
assert!(!status.success());
let request = server.join().unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
}
#[test]
fn client_to_service_regression_routes_zhihu_through_callback_host_without_invalid_hmac_seed_output() {
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
let (event_tx, event_rx) = mpsc::channel();
let (browser_ws_url, browser_server) = start_callback_host_hotlist_browser_server(event_tx);
let root = std::env::temp_dir().join(format!("sgclaw-service-task-flow-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
&config_path,
format!(
r#"{{
"apiKey": "sk-runtime",
"baseUrl": "http://127.0.0.1:9",
"model": "deepseek-chat",
"browserWsUrl": "{browser_ws_url}",
"serviceWsListenAddr": "{service_addr}"
}}"#
),
)
.unwrap();
let mut service = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw").expect("sg_claw test binary path"),
)
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
.arg("--config-path")
.arg(&config_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let ws_url = format!("ws://{service_addr}");
let ready_deadline = Instant::now() + Duration::from_secs(2);
let mut service_stderr_boot = String::new();
while Instant::now() < ready_deadline {
if let Some(stream) = service.stderr.as_mut() {
let mut buf = [0_u8; 1024];
match stream.read(&mut buf) {
Ok(0) => {}
Ok(n) => {
service_stderr_boot.push_str(&String::from_utf8_lossy(&buf[..n]));
if service_stderr_boot.contains("sg_claw ready:") {
break;
}
}
Err(_) => {}
}
}
if service.try_wait().unwrap().is_some() {
break;
}
thread::sleep(Duration::from_millis(20));
}
assert!(
service_stderr_boot.contains("sg_claw ready:"),
"service did not report readiness; stderr={service_stderr_boot}"
);
let mut client = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
client
.stdin
.as_mut()
.unwrap()
.write_all("打开知乎热榜获取前10条数据并导出 Excel\n".as_bytes())
.unwrap();
let client_output = client.wait_with_output().unwrap();
browser_server.join().unwrap();
let register = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let bootstrap = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let pre_ready = event_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let open_page = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
let get_text = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
let eval = event_rx.recv_timeout(Duration::from_secs(4)).unwrap();
let exit_deadline = Instant::now() + Duration::from_secs(1);
let mut service_status = None;
while Instant::now() < exit_deadline {
if let Some(status) = service.try_wait().unwrap() {
service_status = Some(status);
break;
}
thread::sleep(Duration::from_millis(20));
}
if service_status.is_none() {
service.kill().unwrap();
let _ = service.wait();
}
let service_stdout = service
.stdout
.take()
.map(|mut stream| {
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
})
.unwrap_or_default();
let service_stderr = service
.stderr
.take()
.map(|mut stream| {
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
})
.unwrap_or_default();
let client_stdout = String::from_utf8_lossy(&client_output.stdout).into_owned();
let client_stderr = String::from_utf8_lossy(&client_output.stderr).into_owned();
let combined_output = format!("{client_stdout}\n{client_stderr}\n{service_stdout}\n{service_stderr}");
let register = match register {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
other => panic!("expected register browser frame, got {other:?}"),
};
assert_eq!(register, json!({ "type": "register", "role": "web" }));
let bootstrap = match bootstrap {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
other => panic!("expected helper bootstrap frame, got {other:?}"),
};
assert_eq!(bootstrap[0], json!("https://www.zhihu.com"));
assert_eq!(bootstrap[1], json!("sgBrowerserOpenPage"));
assert!(bootstrap[2]
.as_str()
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html")));
let pre_ready = match pre_ready {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected pre-ready command envelope, got {other:?}"),
};
assert_eq!(pre_ready, json!({ "ok": false, "command": null }));
let open_page = match open_page {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected open-page command envelope, got {other:?}"),
};
assert_eq!(open_page["command"]["action"], json!("sgBrowerserOpenPage"));
assert_eq!(open_page["command"]["args"][0], json!("https://www.zhihu.com/hot"));
let get_text = match get_text {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected getText command envelope, got {other:?}"),
};
assert_eq!(get_text["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(get_text["command"]["args"][0], json!("www.zhihu.com"));
assert!(get_text["command"]["args"][1]
.as_str()
.is_some_and(|script| script.contains("sgclawOnGetText")));
let eval = match eval {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected eval command envelope, got {other:?}"),
};
assert_eq!(eval["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(eval["command"]["args"][0], json!("www.zhihu.com"));
assert!(eval["command"]["args"][1]
.as_str()
.is_some_and(|script| script.contains("sgclawOnEval")));
assert!(client_output.status.success());
assert!(client_stdout.contains("已导出并打开知乎热榜 Excel"), "client stdout={client_stdout}");
assert!(client_stdout.contains(".xlsx"), "client stdout={client_stdout}");
assert!(
!combined_output.contains("invalid hmac seed: session key must not be empty"),
"target behavior must avoid the invalid hmac seed failure; combined_output={combined_output}"
);
assert!(
!combined_output.contains(RUNTIME_DROP_PANIC_TEXT),
"target behavior must avoid the runtime-drop panic; combined_output={combined_output}"
);
}

File diff suppressed because it is too large Load Diff

471
tests/task_runner_test.rs Normal file
View File

@@ -0,0 +1,471 @@
mod common;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use common::MockTransport;
use serde_json::Value;
use sgclaw::agent::{run_submit_task, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest};
use sgclaw::agent::task_runner::run_submit_task_with_browser_backend;
use sgclaw::browser::BrowserBackend;
use sgclaw::pipe::{
Action, AgentMessage, BrowserMessage, BrowserPipeTool, CommandOutput, ConversationMessage,
ExecutionSurfaceMetadata, PipeError, 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": ["oa.example.com", "www.baidu.com"] },
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText"],
"blocked": ["eval", "executeJsInPage"]
}
}"#,
)
.unwrap()
}
fn test_browser_tool(transport: Arc<MockTransport>) -> BrowserPipeTool<MockTransport> {
BrowserPipeTool::new(
transport,
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1))
}
#[derive(Clone, Default)]
struct StubBrowserBackend;
impl BrowserBackend for StubBrowserBackend {
fn invoke(
&self,
_action: Action,
_params: Value,
_expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
Err(PipeError::Protocol(
"stub backend should not be invoked in this test".to_string(),
))
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
ExecutionSurfaceMetadata::privileged_browser_pipe("stub-backend")
}
}
#[derive(Default)]
struct RecordingSink {
sent: Mutex<Vec<AgentMessage>>,
}
impl RecordingSink {
fn sent_messages(&self) -> Vec<AgentMessage> {
self.sent.lock().unwrap().clone()
}
}
impl AgentEventSink for RecordingSink {
fn send(&self, message: &AgentMessage) -> Result<(), PipeError> {
self.sent.lock().unwrap().push(message.clone());
Ok(())
}
}
fn temp_workspace_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("sgclaw-task-runner-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn run_submit_task_with_browser_backend_accepts_ws_only_backend_and_preserves_existing_entry() {
let _existing_entry: fn(
&MockTransport,
&dyn AgentEventSink,
&BrowserPipeTool<MockTransport>,
&AgentRuntimeContext,
SubmitTaskRequest,
) -> Result<(), PipeError> = run_submit_task::<MockTransport>;
let _ws_only_entry: fn(
&MockTransport,
&dyn AgentEventSink,
Arc<dyn BrowserBackend>,
&AgentRuntimeContext,
SubmitTaskRequest,
) -> Result<(), PipeError> = run_submit_task_with_browser_backend::<MockTransport>;
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let sink = RecordingSink::default();
let backend: Arc<dyn BrowserBackend> = Arc::new(StubBrowserBackend);
run_submit_task_with_browser_backend(
transport.as_ref(),
&sink,
backend,
&AgentRuntimeContext::default(),
SubmitTaskRequest {
instruction: "打开百度搜索天气".to_string(),
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = sink.sent_messages();
assert!(transport.sent_messages().is_empty());
assert_eq!(sent.len(), 2);
assert!(matches!(&sent[0], AgentMessage::LogEntry { level, .. } if level == "info"));
assert!(matches!(
&sent[1],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("未配置大语言模型")
));
}
#[test]
fn run_submit_task_rejects_blank_instruction_without_emitting_logs() {
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
run_submit_task(
transport.as_ref(),
transport.as_ref(),
&browser_tool,
&AgentRuntimeContext::default(),
SubmitTaskRequest {
instruction: " ".to_string(),
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(
sent[0],
AgentMessage::TaskComplete {
success: false,
summary: "请输入任务内容。".to_string(),
}
);
}
#[test]
fn run_submit_task_can_emit_to_custom_sink() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
let sink = RecordingSink::default();
run_submit_task(
transport.as_ref(),
&sink,
&browser_tool,
&AgentRuntimeContext::default(),
SubmitTaskRequest {
instruction: "打开百度搜索天气".to_string(),
messages: vec![ConversationMessage {
role: "user".to_string(),
content: "上一轮问题".to_string(),
}],
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = sink.sent_messages();
assert!(transport.sent_messages().is_empty());
assert_eq!(sent.len(), 3);
assert!(matches!(
&sent[0],
AgentMessage::LogEntry { level, message }
if level == "info"
&& message
== &format!(
"sgclaw runtime version={} protocol={}",
env!("CARGO_PKG_VERSION"),
sgclaw::pipe::protocol::PROTOCOL_VERSION
)
));
assert!(matches!(
&sent[1],
AgentMessage::LogEntry { level, message }
if level == "info" && message == "continuing conversation with 1 prior turns"
));
assert!(matches!(
&sent[2],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("未配置大语言模型")
));
}
#[test]
fn run_submit_task_without_llm_config_emits_runtime_version_then_failure() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
run_submit_task(
transport.as_ref(),
transport.as_ref(),
&browser_tool,
&AgentRuntimeContext::default(),
SubmitTaskRequest {
instruction: "打开百度搜索天气".to_string(),
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(sent.len(), 2);
assert!(matches!(
&sent[0],
AgentMessage::LogEntry { level, message }
if level == "info"
&& message
== &format!(
"sgclaw runtime version={} protocol={}",
env!("CARGO_PKG_VERSION"),
sgclaw::pipe::protocol::PROTOCOL_VERSION
)
));
assert!(matches!(
&sent[1],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("未配置大语言模型")
));
}
#[test]
fn run_submit_task_logs_prior_turn_count_before_completion() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
run_submit_task(
transport.as_ref(),
transport.as_ref(),
&browser_tool,
&AgentRuntimeContext::default(),
SubmitTaskRequest {
instruction: "继续处理当前页面".to_string(),
messages: vec![
ConversationMessage {
role: "user".to_string(),
content: "上一轮问题".to_string(),
},
ConversationMessage {
role: "assistant".to_string(),
content: "上一轮回答".to_string(),
},
],
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(sent.len(), 3);
assert!(matches!(
&sent[0],
AgentMessage::LogEntry { level, message }
if level == "info"
&& message
== &format!(
"sgclaw runtime version={} protocol={}",
env!("CARGO_PKG_VERSION"),
sgclaw::pipe::protocol::PROTOCOL_VERSION
)
));
assert!(matches!(
&sent[1],
AgentMessage::LogEntry { level, message }
if level == "info" && message == "continuing conversation with 2 prior turns"
));
assert!(matches!(
&sent[2],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("未配置大语言模型")
));
}
#[test]
fn run_submit_task_reports_settings_load_error_and_final_failure() {
let workspace_root = temp_workspace_root();
let config_path = workspace_root.join("sgclaw_config.json");
fs::write(&config_path, "{").unwrap();
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
let context = AgentRuntimeContext::new(Some(config_path), workspace_root);
run_submit_task(
transport.as_ref(),
transport.as_ref(),
&browser_tool,
&context,
SubmitTaskRequest {
instruction: "打开百度".to_string(),
..SubmitTaskRequest::default()
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(sent.len(), 3);
assert!(matches!(
&sent[0],
AgentMessage::LogEntry { level, message }
if level == "info"
&& message
== &format!(
"sgclaw runtime version={} protocol={}",
env!("CARGO_PKG_VERSION"),
sgclaw::pipe::protocol::PROTOCOL_VERSION
)
));
assert!(matches!(
&sent[1],
AgentMessage::LogEntry { level, message }
if level == "error" && message.starts_with("failed to load DeepSeek config:")
));
assert!(matches!(
&sent[2],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("invalid DeepSeek config JSON")
));
}
#[test]
fn handle_browser_message_normalizes_empty_optional_submit_fields() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::SubmitTask {
instruction: "打开百度".to_string(),
conversation_id: " ".to_string(),
messages: vec![],
page_url: "".to_string(),
page_title: "\n\t".to_string(),
},
)
.unwrap();
let sent = transport.sent_messages();
assert_eq!(sent.len(), 2);
assert!(matches!(&sent[0], AgentMessage::LogEntry { .. }));
assert!(matches!(
&sent[1],
AgentMessage::TaskComplete { success, summary }
if !success && summary.contains("未配置大语言模型")
));
}
#[test]
fn handle_browser_message_emits_status_for_lifecycle_messages() {
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Connect,
)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Start,
)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Stop,
)
.unwrap();
assert_eq!(
transport.sent_messages(),
vec![
AgentMessage::StatusChanged {
state: "connected".to_string(),
},
AgentMessage::StatusChanged {
state: "started".to_string(),
},
AgentMessage::StatusChanged {
state: "stopped".to_string(),
},
]
);
}
#[test]
fn handle_browser_message_still_ignores_init_and_unsolicited_response() {
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = test_browser_tool(transport.clone());
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Init {
version: "1.0".to_string(),
hmac_seed: "seed".to_string(),
capabilities: vec![],
},
)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Response {
seq: 1,
success: true,
data: serde_json::json!({}),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
},
)
.unwrap();
assert!(transport.sent_messages().is_empty());
}