1204 lines
38 KiB
Rust
1204 lines
38 KiB
Rust
mod common;
|
||
|
||
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 serde_json::{json, Value};
|
||
use uuid::Uuid;
|
||
|
||
use common::MockTransport;
|
||
use sgclaw::agent::{
|
||
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
|
||
};
|
||
use sgclaw::compat::runtime::CompatTaskContext;
|
||
use sgclaw::config::SgClawSettings;
|
||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||
use sgclaw::security::MacPolicy;
|
||
use tungstenite::{accept, error::ProtocolError, Message};
|
||
|
||
fn env_lock() -> &'static Mutex<()> {
|
||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
LOCK.get_or_init(|| Mutex::new(()))
|
||
}
|
||
|
||
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>,
|
||
browser_ws_url: Option<&str>,
|
||
direct_submit_skill: 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);
|
||
}
|
||
if let Some(browser_ws_url) = browser_ws_url {
|
||
payload["browserWsUrl"] = json!(browser_ws_url);
|
||
}
|
||
if let Some(direct_submit_skill) = direct_submit_skill {
|
||
payload["directSubmitSkill"] = json!(direct_submit_skill);
|
||
}
|
||
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)
|
||
| Err(tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake)) => {
|
||
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)
|
||
}
|
||
|
||
#[test]
|
||
fn browser_ws_server_treats_reset_without_closing_handshake_as_disconnect() {
|
||
let err = tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake);
|
||
assert!(matches!(
|
||
err,
|
||
tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake)
|
||
));
|
||
}
|
||
|
||
fn provider_path_test_policy() -> MacPolicy {
|
||
policy_for_domains(&["www.baidu.com"])
|
||
}
|
||
|
||
fn direct_runtime_test_policy() -> MacPolicy {
|
||
policy_for_domains(&["95598.sgcc.com.cn"])
|
||
}
|
||
|
||
fn test_policy() -> MacPolicy {
|
||
policy_for_domains(&["www.zhihu.com"])
|
||
}
|
||
|
||
fn policy_for_domains(domains: &[&str]) -> MacPolicy {
|
||
MacPolicy::from_json_str(
|
||
&serde_json::json!({
|
||
"version": "1.0",
|
||
"domains": { "allowed": domains },
|
||
"pipe_actions": {
|
||
"allowed": ["click", "type", "navigate", "getText", "eval"],
|
||
"blocked": []
|
||
}
|
||
})
|
||
.to_string(),
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
fn build_direct_runtime_skill_root() -> PathBuf {
|
||
let root = std::env::temp_dir().join(format!(
|
||
"sgclaw-agent-runtime-skill-root-{}",
|
||
Uuid::new_v4()
|
||
));
|
||
let skill_dir = root.join("fault-details-report");
|
||
let script_dir = skill_dir.join("scripts");
|
||
|
||
fs::create_dir_all(&script_dir).unwrap();
|
||
fs::write(
|
||
skill_dir.join("SKILL.toml"),
|
||
r#"
|
||
[skill]
|
||
name = "fault-details-report"
|
||
description = "Collect 95598 fault detail data via browser eval."
|
||
version = "0.1.0"
|
||
|
||
[[tools]]
|
||
name = "collect_fault_details"
|
||
description = "Collect structured fault detail rows for a specific period."
|
||
kind = "browser_script"
|
||
command = "scripts/collect_fault_details.js"
|
||
|
||
[tools.args]
|
||
period = "YYYY-MM period to collect."
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
script_dir.join("collect_fault_details.js"),
|
||
r#"
|
||
return {
|
||
fault_type: "outage",
|
||
observed_at: `${args.period}-15 09:00`,
|
||
affected_scope: "line-7",
|
||
expected_domain: args.expected_domain,
|
||
artifact_payload: "report artifact payload"
|
||
};
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
root
|
||
}
|
||
|
||
fn write_direct_submit_config(
|
||
workspace_root: &std::path::Path,
|
||
skill_root: &std::path::Path,
|
||
) -> PathBuf {
|
||
let config_path = workspace_root.join("sgclaw_config.json");
|
||
fs::write(
|
||
&config_path,
|
||
serde_json::json!({
|
||
"providers": [],
|
||
"skillsDir": skill_root,
|
||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.unwrap();
|
||
config_path
|
||
}
|
||
|
||
fn direct_submit_runtime_context(skill_root: &std::path::Path) -> AgentRuntimeContext {
|
||
let workspace_root =
|
||
std::env::temp_dir().join(format!("sgclaw-agent-runtime-workspace-{}", Uuid::new_v4()));
|
||
fs::create_dir_all(&workspace_root).unwrap();
|
||
let config_path = write_direct_submit_config(&workspace_root, skill_root);
|
||
AgentRuntimeContext::new(Some(config_path), workspace_root)
|
||
}
|
||
|
||
fn submit_fault_details_message() -> BrowserMessage {
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "请采集 2026-03 的故障明细并返回结果".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: "https://95598.sgcc.com.cn/".to_string(),
|
||
page_title: "网上国网".to_string(),
|
||
}
|
||
}
|
||
|
||
fn submit_zhihu_hotlist_export_message() -> BrowserMessage {
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
}
|
||
}
|
||
|
||
fn build_manifest_scene_skill_root() -> PathBuf {
|
||
let root = std::env::temp_dir().join(format!(
|
||
"sgclaw-agent-runtime-scene-skill-root-{}",
|
||
Uuid::new_v4()
|
||
));
|
||
let skill_dir = root.join("manifest-scene-report");
|
||
let script_dir = skill_dir.join("scripts");
|
||
fs::create_dir_all(&script_dir).unwrap();
|
||
fs::write(
|
||
skill_dir.join("SKILL.toml"),
|
||
r#"
|
||
[skill]
|
||
name = "manifest-scene-report"
|
||
description = "Collect manifest scene report data."
|
||
version = "0.1.0"
|
||
|
||
[[tools]]
|
||
name = "collect_manifest_scene"
|
||
description = "Collect manifest scene report rows."
|
||
kind = "browser_script"
|
||
command = "scripts/collect_manifest_scene.js"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
skill_dir.join("scene.toml"),
|
||
r#"
|
||
[scene]
|
||
id = "manifest-scene-report"
|
||
skill = "manifest-scene-report"
|
||
tool = "collect_manifest_scene"
|
||
kind = "browser_script"
|
||
version = "0.1.0"
|
||
category = "report_collection"
|
||
|
||
[manifest]
|
||
schema_version = "1"
|
||
|
||
[bootstrap]
|
||
expected_domain = "manifest.example.test"
|
||
target_url = "https://manifest.example.test/report"
|
||
page_title_keywords = []
|
||
requires_target_page = true
|
||
|
||
[deterministic]
|
||
suffix = "。。。"
|
||
include_keywords = ["自定义场景报表"]
|
||
exclude_keywords = ["知乎"]
|
||
|
||
[[params]]
|
||
name = "period"
|
||
resolver = "literal_passthrough"
|
||
required = false
|
||
prompt_missing = "missing"
|
||
prompt_ambiguous = "ambiguous"
|
||
|
||
[params.resolver_config]
|
||
output_field = "period_value"
|
||
value = "2026-03"
|
||
|
||
[artifact]
|
||
type = "report-artifact"
|
||
success_status = ["ok", "partial", "empty"]
|
||
failure_status = ["blocked", "error"]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
script_dir.join("collect_manifest_scene.js"),
|
||
r#"
|
||
return {
|
||
type: "report-artifact",
|
||
report_name: "manifest-scene-report",
|
||
status: "ok",
|
||
columns: ["period_value"],
|
||
rows: [{ period_value: args.period_value }],
|
||
counts: { rows: 1 }
|
||
};
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
root
|
||
}
|
||
|
||
fn direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec<String> {
|
||
sent.iter()
|
||
.filter_map(|message| match message {
|
||
AgentMessage::LogEntry { level, message } if level == "mode" => Some(message.clone()),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn direct_submit_completion(sent: &[AgentMessage]) -> Option<(bool, String)> {
|
||
sent.iter().find_map(|message| match message {
|
||
AgentMessage::TaskComplete { success, summary } => Some((*success, summary.clone())),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
fn success_browser_response(seq: u64, data: serde_json::Value) -> BrowserMessage {
|
||
BrowserMessage::Response {
|
||
seq,
|
||
success: true,
|
||
data,
|
||
aom_snapshot: vec![],
|
||
timing: Timing {
|
||
queue_ms: 1,
|
||
exec_ms: 10,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn report_artifact_browser_response(
|
||
seq: u64,
|
||
status: &str,
|
||
partial_reasons: &[&str],
|
||
detail_rows: Vec<serde_json::Value>,
|
||
summary_rows: Vec<serde_json::Value>,
|
||
) -> BrowserMessage {
|
||
success_browser_response(
|
||
seq,
|
||
serde_json::json!({
|
||
"text": {
|
||
"type": "report-artifact",
|
||
"report_name": "fault-details-report",
|
||
"period": "2026-03",
|
||
"selected_range": {
|
||
"start": "2026-03-08 16:00:00",
|
||
"end": "2026-03-09 16:00:00"
|
||
},
|
||
"columns": ["qxdbh"],
|
||
"rows": detail_rows,
|
||
"sections": [{
|
||
"name": "summary-sheet",
|
||
"columns": ["index"],
|
||
"rows": summary_rows
|
||
}],
|
||
"counts": {
|
||
"detail_rows": detail_rows.len(),
|
||
"summary_rows": summary_rows.len()
|
||
},
|
||
"status": status,
|
||
"partial_reasons": partial_reasons,
|
||
"downstream": {
|
||
"export": {
|
||
"attempted": true,
|
||
"success": status != "blocked" && status != "error",
|
||
"path": "http://localhost/export.xlsx"
|
||
},
|
||
"report_log": {
|
||
"attempted": true,
|
||
"success": partial_reasons.is_empty(),
|
||
"error": partial_reasons
|
||
.first()
|
||
.copied()
|
||
.unwrap_or("")
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn direct_submit_runtime_executes_fault_details_skill_without_provider_path() {
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
serde_json::json!({
|
||
"text": {
|
||
"fault_type": "outage",
|
||
"observed_at": "2026-03-15 09:00",
|
||
"affected_scope": "line-7"
|
||
}
|
||
}),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||
"unused-key".to_string(),
|
||
"http://127.0.0.1:9".to_string(),
|
||
"unused-model".to_string(),
|
||
Some(skill_root.clone()),
|
||
)
|
||
.unwrap();
|
||
settings.direct_submit_skill = Some("fault-details-report.collect_fault_details".to_string());
|
||
|
||
let summary = sgclaw::compat::direct_skill_runtime::execute_direct_submit_skill(
|
||
browser_tool,
|
||
"请采集 2026-03 的故障明细并返回结果",
|
||
&CompatTaskContext {
|
||
page_url: Some("https://95598.sgcc.com.cn/".to_string()),
|
||
..CompatTaskContext::default()
|
||
},
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).as_path(),
|
||
&settings,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(summary.success);
|
||
assert!(summary.summary.contains("fault_type"));
|
||
let sent = transport.sent_messages();
|
||
assert!(sent.iter().all(|message| !matches!(message, AgentMessage::LogEntry { level, message } if level == "info" && message.contains("DeepSeek config loaded"))));
|
||
assert!(matches!(
|
||
&sent[0],
|
||
AgentMessage::Command {
|
||
seq,
|
||
action,
|
||
params,
|
||
security,
|
||
} if *seq == 1
|
||
&& action == &Action::Eval
|
||
&& security.expected_domain == "95598.sgcc.com.cn"
|
||
&& params["script"].as_str().is_some_and(|script| script.contains("2026-03"))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
serde_json::json!({
|
||
"text": {
|
||
"fault_type": "outage",
|
||
"observed_at": "2026-03-15 09:00",
|
||
"affected_scope": "line-7",
|
||
"artifact_payload": "report artifact payload"
|
||
}
|
||
}),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(
|
||
completion.0,
|
||
"expected direct submit task to succeed: {sent:?}"
|
||
);
|
||
assert!(
|
||
completion.1.contains("report artifact payload"),
|
||
"expected report artifact payload in summary: {}",
|
||
completion.1
|
||
);
|
||
assert!(
|
||
!completion.1.contains("未配置大语言模型"),
|
||
"did not expect missing-llm summary: {}",
|
||
completion.1
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let workspace_root = std::env::temp_dir().join(format!(
|
||
"sgclaw-invalid-direct-submit-workspace-{}",
|
||
Uuid::new_v4()
|
||
));
|
||
fs::create_dir_all(&workspace_root).unwrap();
|
||
let config_path = workspace_root.join("sgclaw_config.json");
|
||
fs::write(
|
||
&config_path,
|
||
serde_json::json!({
|
||
"providers": [],
|
||
"skillsDir": skill_root,
|
||
"directSubmitSkill": "fault-details-report"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.unwrap();
|
||
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
assert!(matches!(
|
||
sent.last(),
|
||
Some(AgentMessage::TaskComplete { success, summary })
|
||
if !success && summary.contains("skill.tool")
|
||
));
|
||
assert!(direct_submit_mode_logs(&sent).is_empty());
|
||
assert!(!sent
|
||
.iter()
|
||
.any(|message| matches!(message, AgentMessage::Command { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_treats_partial_report_artifact_as_success_with_warning_summary() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||
1,
|
||
"partial",
|
||
&["report_log_failed"],
|
||
vec![serde_json::json!({ "qxdbh": "QX-1" })],
|
||
vec![serde_json::json!({ "index": 1 })],
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(
|
||
completion.0,
|
||
"expected partial artifact to succeed: {sent:?}"
|
||
);
|
||
assert!(completion.1.contains("fault-details-report"));
|
||
assert!(completion.1.contains("2026-03"));
|
||
assert!(completion.1.contains("status=partial"));
|
||
assert!(completion.1.contains("detail_rows=1"));
|
||
assert!(completion.1.contains("summary_rows=1"));
|
||
assert!(completion.1.contains("report_log_failed"));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_treats_empty_report_artifact_as_success() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||
1,
|
||
"empty",
|
||
&[],
|
||
vec![],
|
||
vec![],
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(completion.0, "expected empty artifact to succeed: {sent:?}");
|
||
assert!(completion.1.contains("status=empty"));
|
||
assert!(completion.1.contains("detail_rows=0"));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_treats_blocked_report_artifact_as_failure() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||
1,
|
||
"blocked",
|
||
&["selected_range_unavailable"],
|
||
vec![],
|
||
vec![],
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(!completion.0, "expected blocked artifact to fail: {sent:?}");
|
||
assert!(completion.1.contains("status=blocked"));
|
||
assert!(completion.1.contains("selected_range_unavailable"));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_treats_error_report_artifact_as_failure() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![report_artifact_browser_response(
|
||
1,
|
||
"error",
|
||
&["detail_normalization_failed"],
|
||
vec![],
|
||
vec![],
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(!completion.0, "expected error artifact to fail: {sent:?}");
|
||
assert!(completion.1.contains("status=error"));
|
||
assert!(completion.1.contains("detail_normalization_failed"));
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_routes_zhihu_hotlist_export_before_direct_submit() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
policy_for_domains(&["www.zhihu.com"]),
|
||
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,
|
||
submit_zhihu_hotlist_export_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let mode_logs = direct_submit_mode_logs(&sent);
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert_eq!(
|
||
mode_logs,
|
||
vec!["zeroclaw_process_message_primary".to_string()]
|
||
);
|
||
assert!(
|
||
!completion.0,
|
||
"expected zhihu export without page context to fail before browser actions: {sent:?}"
|
||
);
|
||
assert!(
|
||
!completion
|
||
.1
|
||
.contains("direct submit skill requires page_url so expected_domain can be derived"),
|
||
"unexpected direct submit fallback: {sent:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn direct_skill_mode_logs_direct_skill_primary() {
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let skill_root = build_direct_runtime_skill_root();
|
||
let runtime_context = direct_submit_runtime_context(&skill_root);
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
serde_json::json!({
|
||
"text": {
|
||
"fault_type": "outage",
|
||
"observed_at": "2026-03-15 09:00",
|
||
"affected_scope": "line-7",
|
||
"artifact_payload": "report artifact payload"
|
||
}
|
||
}),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
direct_runtime_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,
|
||
submit_fault_details_message(),
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let mode_logs = direct_submit_mode_logs(&sent);
|
||
|
||
assert_eq!(mode_logs, vec!["direct_skill_primary".to_string()]);
|
||
assert!(
|
||
!mode_logs.iter().any(|mode| mode == "compat_llm_primary"),
|
||
"unexpected compat mode logs: {mode_logs:?}"
|
||
);
|
||
assert!(
|
||
!mode_logs
|
||
.iter()
|
||
.any(|mode| mode == "zeroclaw_process_message_primary"),
|
||
"unexpected zeroclaw mode logs: {mode_logs:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn production_submit_task_with_ws_and_direct_submit_config_routes_zhihu_before_direct_submit() {
|
||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||
std::env::set_var("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1");
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let (ws_url, _frames, ws_handle) = start_browser_ws_server();
|
||
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()),
|
||
Some(&ws_url),
|
||
Some("fault-details-report.collect_fault_details"),
|
||
);
|
||
|
||
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 runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
ws_handle.join().unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let mode_logs = direct_submit_mode_logs(&sent);
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
|
||
assert!(
|
||
mode_logs
|
||
.iter()
|
||
.any(|mode| mode == "zeroclaw_process_message_primary"),
|
||
"expected orchestration mode log before direct submit: {sent:?}"
|
||
);
|
||
assert!(
|
||
!mode_logs.iter().any(|mode| mode == "direct_skill_primary"),
|
||
"unexpected direct submit mode log for zhihu ws submit: {sent:?}"
|
||
);
|
||
assert!(
|
||
completion.0,
|
||
"expected zhihu ws submit to succeed: {sent:?}"
|
||
);
|
||
assert!(
|
||
!completion
|
||
.1
|
||
.contains("direct submit skill requires page_url so expected_domain can be derived"),
|
||
"unexpected direct-submit page_url failure: {sent:?}"
|
||
);
|
||
|
||
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
|
||
}
|
||
|
||
#[test]
|
||
fn submit_task_routes_configured_manifest_scene_before_llm_provider() {
|
||
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 skill_root = build_manifest_scene_skill_root();
|
||
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(skill_root.to_str().unwrap()),
|
||
None,
|
||
None,
|
||
);
|
||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||
1,
|
||
serde_json::json!({
|
||
"text": {
|
||
"type": "report-artifact",
|
||
"report_name": "manifest-scene-report",
|
||
"status": "ok",
|
||
"columns": ["period_value"],
|
||
"rows": [{"period_value": "2026-03"}],
|
||
"counts": {"rows": 1}
|
||
}
|
||
}),
|
||
)]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
policy_for_domains(&["manifest.example.test"]),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "请执行自定义场景报表。。。".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
let completion = direct_submit_completion(&sent).expect("task completion");
|
||
assert!(completion.0, "expected manifest scene success: {sent:?}");
|
||
assert!(completion.1.contains("manifest-scene-report"));
|
||
assert!(completion.1.contains("detail_rows=1"));
|
||
assert!(sent.iter().any(|message| matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "direct_skill_primary"
|
||
)));
|
||
assert!(!sent.iter().any(|message| matches!(
|
||
message,
|
||
AgentMessage::LogEntry { level, message }
|
||
if level == "mode" && message == "compat_llm_primary"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
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");
|
||
|
||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||
std::env::remove_var("DEEPSEEK_MODEL");
|
||
|
||
let workspace_root = temp_workspace_root();
|
||
let (ws_url, frames, ws_handle) = start_browser_ws_server();
|
||
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()),
|
||
Some(&ws_url),
|
||
None,
|
||
);
|
||
|
||
let transport = Arc::new(MockTransport::new(vec![]));
|
||
let browser_tool = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||
|
||
handle_browser_message_with_context(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
&runtime_context,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
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 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]
|
||
fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config() {
|
||
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 = BrowserPipeTool::new(
|
||
transport.clone(),
|
||
provider_path_test_policy(),
|
||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||
)
|
||
.with_response_timeout(Duration::from_secs(1));
|
||
|
||
handle_browser_message(
|
||
transport.as_ref(),
|
||
&browser_tool,
|
||
BrowserMessage::SubmitTask {
|
||
instruction: "打开百度".to_string(),
|
||
conversation_id: String::new(),
|
||
messages: vec![],
|
||
page_url: String::new(),
|
||
page_title: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
let sent = transport.sent_messages();
|
||
assert!(matches!(
|
||
sent.last(),
|
||
Some(AgentMessage::TaskComplete { success, summary })
|
||
if !success && summary.contains("未配置大语言模型")
|
||
));
|
||
assert!(!sent
|
||
.iter()
|
||
.any(|message| { matches!(message, AgentMessage::Command { .. }) }));
|
||
}
|