feat: align browser callback runtime and export flows
Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
471
tests/task_runner_test.rs
Normal file
471
tests/task_runner_test.rs
Normal file
@@ -0,0 +1,471 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use common::MockTransport;
|
||||
use serde_json::Value;
|
||||
use sgclaw::agent::{run_submit_task, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest};
|
||||
use sgclaw::agent::task_runner::run_submit_task_with_browser_backend;
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user