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