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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user