462 lines
14 KiB
Rust
462 lines
14 KiB
Rust
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<Mutex<()>> = 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<MockTransport>) -> BrowserPipeTool<MockTransport> {
|
|
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<CommandOutput, PipeError> {
|
|
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<Vec<AgentMessage>>,
|
|
}
|
|
|
|
impl RecordingSink {
|
|
fn sent_messages(&self) -> Vec<AgentMessage> {
|
|
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<MockTransport>,
|
|
&AgentRuntimeContext,
|
|
SubmitTaskRequest,
|
|
) -> Result<(), PipeError> = run_submit_task::<MockTransport>;
|
|
let _ws_only_entry: fn(
|
|
&MockTransport,
|
|
&dyn AgentEventSink,
|
|
Arc<dyn BrowserBackend>,
|
|
&AgentRuntimeContext,
|
|
SubmitTaskRequest,
|
|
) -> Result<(), PipeError> = run_submit_task_with_browser_backend::<MockTransport>;
|
|
|
|
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<dyn BrowserBackend> = 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());
|
|
}
|