Files
claw/tests/task_runner_test.rs

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());
}