mod common; use std::fs; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use uuid::Uuid; use common::MockTransport; use sgclaw::agent::{ handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext, }; use sgclaw::agent::runtime::{browser_action_tool_definition, execute_task_with_provider}; use sgclaw::compat::runtime::CompatTaskContext; use sgclaw::config::SgClawSettings; use sgclaw::llm::{ChatMessage, LlmError, LlmProvider, ToolDefinition, ToolFunctionCall}; use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing}; use sgclaw::security::MacPolicy; struct FakeProvider { calls: Vec, } impl LlmProvider for FakeProvider { fn chat( &self, _messages: &[ChatMessage], _tools: &[ToolDefinition], ) -> Result, LlmError> { Ok(self.calls.clone()) } } 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 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 direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec { 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, }, } } #[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.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 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 browser_action_tool_definition_uses_expected_name() { let tool = browser_action_tool_definition(); assert_eq!(tool.name, "browser_action"); assert_eq!(tool.parameters["required"][0], "action"); assert_eq!(tool.parameters["required"][1], "expected_domain"); } #[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 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)); 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 summary = execute_task_with_provider( transport.as_ref(), &browser_tool, &provider, "打开百度搜索天气", ) .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 )); } #[test] fn legacy_agent_runtime_is_explicitly_dev_only() { assert!(sgclaw::agent::runtime::LEGACY_DEV_ONLY); } #[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 { .. }) })); }