feat: add config-owned direct submit runtime
Keep browser-attached workflows on the configured direct-skill path and align the Zhihu export/browser regression contracts with the current ws merge state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,15 +7,18 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
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::pipe::{AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::compat::runtime::CompatTaskContext;
|
||||
use sgclaw::config::SgClawSettings;
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use tungstenite::{accept, Message};
|
||||
use uuid::Uuid;
|
||||
use tungstenite::{accept, error::ProtocolError, Message};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -34,6 +37,7 @@ fn write_config(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
skills_dir: Option<&str>,
|
||||
browser_ws_url: Option<&str>,
|
||||
) -> PathBuf {
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
let mut payload = json!({
|
||||
@@ -45,6 +49,9 @@ fn write_config(
|
||||
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);
|
||||
}
|
||||
fs::write(&config_path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
|
||||
config_path
|
||||
}
|
||||
@@ -80,7 +87,10 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
|
||||
let message = match socket.read() {
|
||||
Ok(message) => message,
|
||||
Err(tungstenite::Error::ConnectionClosed)
|
||||
| Err(tungstenite::Error::AlreadyClosed) => break,
|
||||
| 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 {
|
||||
@@ -155,20 +165,567 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
|
||||
(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(
|
||||
r#"{
|
||||
&serde_json::json!({
|
||||
"version": "1.0",
|
||||
"domains": { "allowed": ["www.baidu.com", "www.zhihu.com"] },
|
||||
"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 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 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_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
@@ -179,17 +736,16 @@ fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstr
|
||||
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),
|
||||
);
|
||||
|
||||
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(),
|
||||
@@ -306,7 +862,7 @@ fn production_submit_task_does_not_route_into_legacy_runtime_without_llm_config(
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
provider_path_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
@@ -32,6 +32,174 @@ fn test_policy() -> MacPolicy {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_runs_packaged_script_with_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("extract_hotlist.js"),
|
||||
"return { wrapped_args: args, source: \"packaged script\" };\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
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 mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = 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: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&PipeBrowserBackend::from_inner(browser_tool),
|
||||
json!({
|
||||
"expected_domain": "https://WWW.ZHIHU.COM/hot?foo=bar",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10\"};")
|
||||
&& params["script"].as_str().unwrap().contains("source: \"packaged script\"")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_rejects_non_browser_script_tool_kind() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-kind");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "shell".to_string(),
|
||||
command: "scripts/extract_hotlist.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&PipeBrowserBackend::from_inner(browser_tool),
|
||||
json!({
|
||||
"expected_domain": "www.zhihu.com",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("browser script tool kind must be browser_script, got shell")
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_rejects_missing_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-invalid-domain");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("extract_hotlist.js"), "return 'unused';\n").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("top_n".to_string(), "How many rows to extract".to_string());
|
||||
let skill_tool = 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: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&PipeBrowserBackend::from_inner(browser_tool),
|
||||
json!({
|
||||
"expected_domain": " ",
|
||||
"top_n": "10"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("expected_domain must be a non-empty string, got \" \"")
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_skill_tool_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-skill");
|
||||
@@ -115,9 +283,91 @@ return {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_skill_tool_executes_script_directly_under_skill_root() {
|
||||
let skill_root = unique_temp_dir("sgclaw-browser-script-direct-root");
|
||||
let script_name = "extract_hotlist_direct.js";
|
||||
let script_path = skill_root.join(script_name);
|
||||
fs::write(
|
||||
&script_path,
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "知乎热榜",
|
||||
rows: [[1, "标题", args.top_n]]
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
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 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());
|
||||
let skill_tool = SkillTool {
|
||||
name: "extract_hotlist".to_string(),
|
||||
description: "Extract structured hotlist rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: script_name.to_string(),
|
||||
args,
|
||||
};
|
||||
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, backend)
|
||||
.unwrap();
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"top_n": "10条"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "知乎热榜",
|
||||
"rows": [[1, "标题", "10条"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"top_n\":\"10条\"};")
|
||||
&& params["script"].as_str().unwrap().contains("rows: [[1, \"标题\", args.top_n]]")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-fault-details");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
@@ -152,7 +402,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 backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
@@ -164,10 +414,15 @@ return {
|
||||
args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}))
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&backend,
|
||||
json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -208,7 +463,7 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
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 backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
@@ -220,9 +475,14 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"period": "2026-04"
|
||||
}))
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&backend,
|
||||
json!({
|
||||
"period": "2026-04"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -234,6 +494,122 @@ async fn browser_script_helper_requires_expected_domain() {
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_browser_script_tool_preserves_structured_report_artifact_payload() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-report-artifact");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("collect_fault_details.js"),
|
||||
r#"
|
||||
return {
|
||||
type: "report-artifact",
|
||||
report_name: "fault-details-report",
|
||||
period: args.period,
|
||||
selected_range: {
|
||||
start: "2026-03-08 16:00:00",
|
||||
end: "2026-03-09 16:00:00"
|
||||
},
|
||||
columns: ["qxdbh"],
|
||||
rows: [{ qxdbh: "QX-1" }],
|
||||
sections: [{ name: "summary-sheet", columns: ["index"], rows: [{ index: 1 }] }],
|
||||
counts: { detail_rows: 1, summary_rows: 1 },
|
||||
status: "partial",
|
||||
partial_reasons: ["report_log_failed"],
|
||||
downstream: {
|
||||
export: { attempted: true, success: true, path: "http://localhost/export.xlsx" },
|
||||
report_log: { attempted: true, success: false, error: "500" }
|
||||
}
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: 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": [{ "qxdbh": "QX-1" }],
|
||||
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"],
|
||||
"downstream": {
|
||||
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
|
||||
"report_log": { "attempted": true, "success": false, "error": "500" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
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 backend = PipeBrowserBackend::from_inner(browser_tool);
|
||||
|
||||
let mut tool_args = HashMap::new();
|
||||
tool_args.insert("period".to_string(), "YYYY-MM period to collect".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "collect_fault_details".to_string(),
|
||||
description: "Collect structured fault details".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
args: tool_args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(
|
||||
&skill_tool,
|
||||
&skill_dir,
|
||||
&backend,
|
||||
json!({
|
||||
"expected_domain": "https://www.zhihu.com/",
|
||||
"period": "2026-03"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"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": [{ "qxdbh": "QX-1" }],
|
||||
"sections": [{ "name": "summary-sheet", "columns": ["index"], "rows": [{ "index": 1 }] }],
|
||||
"counts": { "detail_rows": 1, "summary_rows": 1 },
|
||||
"status": "partial",
|
||||
"partial_reasons": ["report_log_failed"],
|
||||
"downstream": {
|
||||
"export": { "attempted": true, "success": true, "path": "http://localhost/export.xlsx" },
|
||||
"report_log": { "attempted": true, "success": false, "error": "500" }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -161,6 +161,60 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_direct_submit_only_config_and_resolve_relative_skills_dir() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-direct-submit-only-config-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"providers": [],
|
||||
"skillsDir": "skill_lib",
|
||||
"directSubmitSkill": "fault-details-report.collect_fault_details"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.unwrap()
|
||||
.expect("expected sgclaw settings from config file");
|
||||
|
||||
assert_eq!(
|
||||
settings.direct_submit_skill.as_deref(),
|
||||
Some("fault-details-report.collect_fault_details")
|
||||
);
|
||||
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_reject_invalid_direct_submit_skill_format() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-invalid-direct-submit-skill-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let config_path = root.join("sgclaw_config.json");
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"providers": [],
|
||||
"skillsDir": "skill_lib",
|
||||
"directSubmitSkill": "fault-details-report"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = SgClawSettings::load(Some(config_path.as_path()))
|
||||
.expect_err("expected invalid directSubmitSkill format");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("directSubmitSkill"));
|
||||
assert!(message.contains("skill.tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-runtime-config-{}", Uuid::new_v4()));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::openxml_office_tool::OpenXmlOfficeTool;
|
||||
use uuid::Uuid;
|
||||
use zeroclaw::tools::Tool;
|
||||
use zip::ZipArchive;
|
||||
|
||||
fn temp_workspace_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-openxml-office-{}", Uuid::new_v4()));
|
||||
@@ -12,6 +14,15 @@ fn temp_workspace_root() -> PathBuf {
|
||||
root
|
||||
}
|
||||
|
||||
fn read_sheet_xml(output_path: &std::path::Path) -> String {
|
||||
let file = File::open(output_path).unwrap();
|
||||
let mut archive = ZipArchive::new(file).unwrap();
|
||||
let mut entry = archive.by_name("xl/worksheets/sheet1.xml").unwrap();
|
||||
let mut xml = String::new();
|
||||
entry.read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
let workspace_root = temp_workspace_root();
|
||||
@@ -33,20 +44,12 @@ async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
|
||||
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
let payload: serde_json::Value = serde_json::from_str(&result.output).unwrap();
|
||||
assert_eq!(payload["output_path"], json!(output_path.to_str().unwrap()));
|
||||
let output_json: serde_json::Value = serde_json::from_str(&result.output).unwrap();
|
||||
assert_eq!(output_json["row_count"], 2);
|
||||
assert_eq!(output_json["renderer"], "openxml_office");
|
||||
assert_eq!(output_json["output_path"], json!(output_path.to_str().unwrap()));
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains("问题二"));
|
||||
@@ -75,17 +78,7 @@ async fn openxml_office_tool_accepts_reordered_columns_when_rows_are_structured(
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
@@ -113,17 +106,7 @@ async fn openxml_office_tool_accepts_localized_hotlist_column_aliases() {
|
||||
assert!(result.success, "{result:?}");
|
||||
assert!(output_path.exists());
|
||||
|
||||
let unzip = ProcessCommand::new("unzip")
|
||||
.args([
|
||||
"-p",
|
||||
output_path.to_str().unwrap(),
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(unzip.status.success());
|
||||
|
||||
let xml = String::from_utf8(unzip.stdout).unwrap();
|
||||
let xml = read_sheet_xml(&output_path);
|
||||
assert!(xml.contains("问题一"));
|
||||
assert!(xml.contains("344万"));
|
||||
assert!(xml.contains(">1<"));
|
||||
|
||||
@@ -112,10 +112,15 @@ fn write_skill_script(skill_dir: &std::path::Path, relative_path: &str, body: &s
|
||||
}
|
||||
|
||||
fn real_skill_lib_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
let repo_parent = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("skill_lib")
|
||||
.to_path_buf();
|
||||
let hyphenated = repo_parent.join("skill-lib");
|
||||
if hyphenated.exists() {
|
||||
return hyphenated;
|
||||
}
|
||||
repo_parent.join("skill_lib")
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||||
@@ -349,6 +354,9 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
std::env::remove_var("DEEPSEEK_API_KEY");
|
||||
std::env::remove_var("DEEPSEEK_BASE_URL");
|
||||
std::env::remove_var("DEEPSEEK_MODEL");
|
||||
let sent = transport.sent_messages();
|
||||
|
||||
assert_eq!(summary, "已通过 ZeroClaw 执行任务: 打开百度搜索天气");
|
||||
@@ -676,7 +684,10 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" &&
|
||||
message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||||
message == &format!(
|
||||
"sgclaw runtime version={} protocol=1.0",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
@@ -883,6 +894,11 @@ fn handle_browser_message_falls_back_to_compat_runtime_for_unsupported_instructi
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_requires_llm_configuration_when_no_model_is_available() {
|
||||
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 = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
|
||||
@@ -43,17 +43,18 @@ async fn screen_html_export_tool_renders_dashboard_html_with_presentation_contra
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("file://"));
|
||||
assert!(html.contains("知乎热榜图表驾驶舱"));
|
||||
assert!(html.contains("snapshot-20260329"));
|
||||
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\""));
|
||||
assert!(html.contains("lead-summary"));
|
||||
assert!(html.contains("bar-chart"));
|
||||
assert!(html.contains("top-chart"));
|
||||
assert!(html.contains("pie-chart"));
|
||||
assert!(html.contains("bubble-chart"));
|
||||
assert!(html.contains("metric-categories"));
|
||||
assert!(html.contains("themeMeta"));
|
||||
assert!(html.contains("screen_html_export"));
|
||||
assert!(html.contains("table-note"));
|
||||
}
|
||||
|
||||
@@ -51,7 +51,12 @@ fn submit_task_without_llm_configuration_returns_clear_error() {
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "sgclaw runtime version=0.1.0 protocol=1.0"
|
||||
if level == "info"
|
||||
&& message
|
||||
== &format!(
|
||||
"sgclaw runtime version={} protocol=1.0",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
));
|
||||
assert!(matches!(
|
||||
&sent[1],
|
||||
|
||||
@@ -12,6 +12,8 @@ use tungstenite::{accept, Message};
|
||||
const RUNTIME_DROP_PANIC_TEXT: &str =
|
||||
"Cannot drop a runtime in a context where blocking is not allowed";
|
||||
|
||||
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
|
||||
|
||||
fn read_ws_text(stream: &mut tungstenite::WebSocket<std::net::TcpStream>) -> String {
|
||||
match stream.read().unwrap() {
|
||||
Message::Text(text) => text.to_string(),
|
||||
@@ -756,6 +758,7 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
|
||||
@@ -14,6 +14,7 @@ use sgclaw::service::{ClientMessage, ServiceEventSink, ServiceMessage, ServiceSe
|
||||
|
||||
const RUNTIME_DROP_PANIC_TEXT: &str =
|
||||
"Cannot drop a runtime in a context where blocking is not allowed";
|
||||
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
|
||||
|
||||
fn read_ws_text<S>(stream: &mut tungstenite::WebSocket<S>) -> String
|
||||
where
|
||||
@@ -808,6 +809,7 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
@@ -981,6 +983,7 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
|
||||
"apiKey": "sk-runtime",
|
||||
"baseUrl": "http://127.0.0.1:9",
|
||||
"model": "deepseek-chat",
|
||||
"skillsDir": "{TEST_ZHIHU_SKILLS_DIR}",
|
||||
"browserWsUrl": "{browser_ws_url}",
|
||||
"serviceWsListenAddr": "{service_addr}"
|
||||
}}"#
|
||||
|
||||
Reference in New Issue
Block a user