From bd83d924805c2cfc889c8a504115e2e8caed0dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Tue, 14 Apr 2026 20:23:24 +0800 Subject: [PATCH] refactor(service): unify submit bootstrap target resolution Use page context, deterministic plans, and direct-skill metadata as the service-owned bootstrap target precedence so callback-host startup no longer relies on line-loss text matching or the old request-url helper. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 66 +++++- Cargo.toml | 1 + src/agent/mod.rs | 17 +- src/agent/task_runner.rs | 4 + src/compat/direct_skill_runtime.rs | 78 +++++++ src/service/mod.rs | 2 +- src/service/server.rs | 327 +++++++++++++++++++++++++---- tests/service_ws_session_test.rs | 52 ++++- 8 files changed, 498 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34f641f..70076e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2339,6 +2339,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2377,6 +2386,7 @@ dependencies = [ "sha2", "thiserror 1.0.69", "tokio", + "toml 0.8.23", "tungstenite 0.29.0", "uuid", "zeroclawlabs", @@ -2740,6 +2750,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2748,13 +2770,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.1", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2764,6 +2795,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -2773,6 +2818,12 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -3444,6 +3495,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.1" @@ -3637,7 +3697,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.29.0", "tokio-util", - "toml", + "toml 1.1.2+spec-1.1.0", "tower", "tower-http", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 709b21a..2c56aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde_json = "1" sha2 = "0.10" thiserror = "1" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } +toml = "0.8" tungstenite = "0.29" uuid = { version = "1", features = ["v4"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 9c4c53e..cddd68c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use crate::browser::ws_backend::WsBrowserBackend; use crate::browser::{BrowserBackend, PipeBrowserBackend}; +use crate::config::SgClawSettings; use crate::pipe::{BrowserMessage, BrowserPipeTool, PipeError, Transport}; pub use task_runner::{ @@ -22,13 +23,27 @@ fn browser_backend_for_submit( request: &SubmitTaskRequest, ) -> Result, PipeError> { if let Some(browser_ws_url) = configured_browser_ws_url(context) { + let settings = context.load_sgclaw_settings()?.unwrap_or( + SgClawSettings::from_legacy_deepseek_fields( + "test-key".to_string(), + "https://example.invalid".to_string(), + "test-model".to_string(), + None, + ) + .map_err(|err| PipeError::Protocol(err.to_string()))?, + ); + let bootstrap_target = crate::service::server::resolve_submit_bootstrap_target( + request, + context.workspace_root(), + &settings, + ); return Ok(Arc::new( WsBrowserBackend::new( Arc::new(crate::service::browser_ws_client::ServiceWsClient::connect( &browser_ws_url, )?), browser_tool.mac_policy().clone(), - crate::service::browser_ws_client::initial_request_url_for_submit_task(request), + bootstrap_target.request_url, ) .with_response_timeout(browser_tool.response_timeout()), )); diff --git a/src/agent/task_runner.rs b/src/agent/task_runner.rs index 0c434ed..d3db90c 100644 --- a/src/agent/task_runner.rs +++ b/src/agent/task_runner.rs @@ -68,6 +68,10 @@ impl AgentRuntimeContext { self.config_path.as_deref() } + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + fn settings_source_label(&self) -> String { match &self.config_path { Some(path) if path.exists() => path.display().to_string(), diff --git a/src/compat/direct_skill_runtime.rs b/src/compat/direct_skill_runtime.rs index 53403a0..e6ea4f3 100644 --- a/src/compat/direct_skill_runtime.rs +++ b/src/compat/direct_skill_runtime.rs @@ -1,7 +1,9 @@ +use std::fs; use std::path::Path; use std::sync::Arc; use reqwest::Url; +use serde::Deserialize; use serde_json::{Map, Value}; use zeroclaw::skills::{load_skills_from_directory, SkillTool}; @@ -12,6 +14,12 @@ use crate::compat::runtime::CompatTaskContext; use crate::config::SgClawSettings; use crate::pipe::{BrowserPipeTool, PipeError, Transport}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DirectSubmitBootstrapMetadata { + pub bootstrap_url: String, + pub expected_domain: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirectSubmitOutcome { pub success: bool, @@ -111,6 +119,32 @@ pub fn execute_browser_script_skill_raw_output_with_browser_backend( ) } +pub(crate) fn resolve_direct_submit_bootstrap_metadata( + configured_tool: &str, + workspace_root: &Path, + settings: &SgClawSettings, +) -> Result, PipeError> { + let (tool, skill_root) = resolve_browser_script_skill(configured_tool, workspace_root, settings)?; + let manifest_path = skill_root.join("SKILL.toml"); + let Ok(manifest) = fs::read_to_string(&manifest_path) else { + return Ok(None); + }; + let Ok(parsed) = toml::from_str::(&manifest) else { + return Ok(None); + }; + + let metadata = parsed + .tools + .into_iter() + .find(|candidate| candidate.name == tool.name) + .and_then(|candidate| candidate.metadata) + .and_then(|metadata| { + normalize_bootstrap_metadata(metadata.bootstrap_url, metadata.expected_domain) + }); + + Ok(metadata) +} + fn resolve_browser_script_skill( configured_tool: &str, workspace_root: &Path, @@ -306,6 +340,50 @@ fn count_summary_rows(counts: Option<&Value>, sections: Option<&Value>) -> usize .unwrap_or(0) } +#[derive(Debug, Deserialize)] +struct DirectSubmitSkillManifest { + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, Deserialize)] +struct DirectSubmitSkillManifestTool { + name: String, + #[serde(default)] + metadata: Option, +} + +#[derive(Debug, Deserialize)] +struct DirectSubmitToolMetadata { + #[serde(default)] + bootstrap_url: Option, + #[serde(default)] + expected_domain: Option, +} + +fn normalize_bootstrap_metadata( + bootstrap_url: Option, + expected_domain: Option, +) -> Option { + let bootstrap_url = bootstrap_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + let parsed = Url::parse(bootstrap_url).ok()?; + if parsed.scheme().is_empty() || parsed.host_str().is_none() { + return None; + } + + Some(DirectSubmitBootstrapMetadata { + bootstrap_url: parsed.to_string(), + expected_domain: expected_domain + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + }) +} + fn parse_configured_tool_name(configured_tool: &str) -> Result<(&str, &str), PipeError> { let (skill_name, tool_name) = configured_tool.split_once('.').ok_or_else(|| { PipeError::Protocol(format!( diff --git a/src/service/mod.rs b/src/service/mod.rs index 1ba50d4..7c50aa7 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -18,7 +18,7 @@ pub use protocol::{ClientMessage, ConfigUpdatePayload, ServiceMessage}; pub use server::{ServiceEventSink, ServiceSession}; pub(crate) mod browser_ws_client { - pub(crate) use super::server::{initial_request_url_for_submit_task, ServiceWsClient}; + pub(crate) use super::server::ServiceWsClient; } #[derive(Debug, Clone, Default, PartialEq, Eq)] diff --git a/src/service/server.rs b/src/service/server.rs index e5fc062..26afd4f 100644 --- a/src/service/server.rs +++ b/src/service/server.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; +use reqwest::Url; #[cfg(test)] use reqwest::blocking::Client; #[cfg(test)] @@ -28,6 +29,7 @@ use crate::browser::bridge_transport::BridgeActionTransport; use crate::browser::{BrowserBackend, BrowserCallbackBackend}; #[cfg(test)] use crate::browser::BridgeBrowserBackend; +use crate::config::SgClawSettings; use crate::pipe::{AgentMessage, BrowserMessage, PipeError, Transport}; #[cfg(test)] use crate::pipe::Timing; @@ -329,7 +331,21 @@ pub(crate) fn serve_client( // Lazily create and cache the browser callback host. On first // task it opens the helper page; subsequent tasks reuse it. if cached_host.is_none() { - let bootstrap_url = initial_request_url_for_submit_task(&request); + let settings = context.load_sgclaw_settings()?.unwrap_or( + SgClawSettings::from_legacy_deepseek_fields( + "test-key".to_string(), + "https://example.invalid".to_string(), + "test-model".to_string(), + None, + ) + .map_err(|err| PipeError::Protocol(err.to_string()))?, + ); + let bootstrap_target = resolve_submit_bootstrap_target( + &request, + context.workspace_root(), + &settings, + ); + let bootstrap_url = bootstrap_target.request_url; match LiveBrowserCallbackHost::start_with_browser_ws_url( browser_ws_url, &bootstrap_url, @@ -419,15 +435,82 @@ pub(crate) fn serve_client( } } -pub(crate) fn initial_request_url_for_submit_task(request: &crate::agent::SubmitTaskRequest) -> String { - request +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubmitBootstrapTarget { + pub request_url: String, + pub expected_domain: Option, + pub source: BootstrapTargetSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BootstrapTargetSource { + PageContext, + DeterministicPlan, + SkillConfig, + Fallback, +} + +pub(crate) fn resolve_submit_bootstrap_target( + request: &crate::agent::SubmitTaskRequest, + workspace_root: &Path, + settings: &SgClawSettings, +) -> SubmitBootstrapTarget { + if let Some(page_url) = request .page_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .or_else(|| derive_request_url_from_instruction(&request.instruction)) - .unwrap_or_else(|| "about:blank".to_string()) + { + return SubmitBootstrapTarget { + request_url: page_url.to_string(), + expected_domain: Url::parse(page_url) + .ok() + .and_then(|url| url.domain().map(ToString::to_string)), + source: BootstrapTargetSource::PageContext, + }; + } + + if let crate::compat::deterministic_submit::DeterministicSubmitDecision::Execute(plan) = + crate::compat::deterministic_submit::decide_deterministic_submit( + &request.instruction, + request.page_url.as_deref(), + request.page_title.as_deref(), + ) + { + return SubmitBootstrapTarget { + request_url: plan.target_url.clone(), + expected_domain: Some(plan.expected_domain.clone()), + source: BootstrapTargetSource::DeterministicPlan, + }; + } + + if let Some(configured_tool) = settings + .direct_submit_skill + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Ok(Some(metadata)) = + crate::compat::direct_skill_runtime::resolve_direct_submit_bootstrap_metadata( + configured_tool, + workspace_root, + settings, + ) + { + return SubmitBootstrapTarget { + request_url: metadata.bootstrap_url, + expected_domain: metadata.expected_domain, + source: BootstrapTargetSource::SkillConfig, + }; + } + } + + SubmitBootstrapTarget { + request_url: derive_request_url_from_instruction(&request.instruction) + .unwrap_or_else(|| "about:blank".to_string()), + expected_domain: None, + source: BootstrapTargetSource::Fallback, + } } fn derive_request_url_from_instruction(instruction: &str) -> Option { @@ -457,12 +540,6 @@ fn derive_request_url_from_instruction(instruction: &str) -> Option { return Some("https://zhuanlan.zhihu.com".to_string()); } - // 台区线损相关 - // TODO: 临时方案,后续应从 skill 配置或 deterministic_submit 解析结果中获取 - if instruction.contains("线损") || instruction.contains("lineloss") { - return Some("http://20.76.57.61:18080".to_string()); - } - None } @@ -834,16 +911,77 @@ fn write_http_json_response(stream: &mut impl std::io::Write, status: &str, body #[cfg(test)] mod tests { use super::*; + use std::fs; use std::net::TcpListener; use std::sync::{mpsc, Arc}; use std::thread; use serde_json::json; + use uuid::Uuid; use crate::agent::SubmitTaskRequest; use crate::browser::BrowserBackend; use crate::pipe::Action; + fn service_test_settings( + skills_dir: Option, + direct_submit_skill: Option<&str>, + ) -> SgClawSettings { + let mut settings = SgClawSettings::from_legacy_deepseek_fields( + "test-key".to_string(), + "https://example.invalid".to_string(), + "test-model".to_string(), + skills_dir, + ) + .expect("settings"); + settings.direct_submit_skill = direct_submit_skill.map(ToString::to_string); + settings + } + + fn staged_skill_staging_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../claw/claw/skills/skill_staging/skills") + .canonicalize() + .expect("staged skills dir") + } + + fn temp_direct_submit_skill_root(bootstrap_url: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "sgclaw-bootstrap-target-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).expect("create script dir"); + fs::write( + skill_dir.join("SKILL.toml"), + format!( + 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.metadata] +bootstrap_url = "{bootstrap_url}" +expected_domain = "95598.sgcc.com.cn" +"# + ), + ) + .expect("write skill manifest"); + fs::write( + script_dir.join("collect_fault_details.js"), + "return { ok: true };\n", + ) + .expect("write skill script"); + root + } + fn service_test_policy() -> MacPolicy { MacPolicy::from_json_str( r#"{ @@ -859,56 +997,165 @@ mod tests { } #[test] - fn initial_request_url_prefers_submit_task_page_url() { + fn page_context_bootstrap_target_wins_over_deterministic_and_skill_fallback() { let request = SubmitTaskRequest { - instruction: "打开知乎热榜".to_string(), - page_url: Some(" https://www.zhihu.com/ ".to_string()), + instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(), + page_url: Some(" https://already-open.example.com/page ".to_string()), ..SubmitTaskRequest::default() }; + let settings = SgClawSettings::from_legacy_deepseek_fields( + "test-key".to_string(), + "https://example.invalid".to_string(), + "test-model".to_string(), + None, + ) + .expect("settings"); - assert_eq!( - initial_request_url_for_submit_task(&request), - "https://www.zhihu.com/" - ); + let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings); + + assert_eq!(target.request_url, "https://already-open.example.com/page"); + assert_eq!(target.expected_domain.as_deref(), Some("already-open.example.com")); + assert_eq!(target.source, BootstrapTargetSource::PageContext); } #[test] - fn initial_request_url_falls_back_to_zhihu_origin_for_hotlist_routes() { + fn whitespace_page_url_does_not_short_circuit_bootstrap_fallback() { let request = SubmitTaskRequest { instruction: "打开知乎热榜,获取前10条数据,并导出 Excel".to_string(), + page_url: Some(" ".to_string()), ..SubmitTaskRequest::default() }; + let settings = SgClawSettings::from_legacy_deepseek_fields( + "test-key".to_string(), + "https://example.invalid".to_string(), + "test-model".to_string(), + None, + ) + .expect("settings"); - assert_eq!( - initial_request_url_for_submit_task(&request), - "https://www.zhihu.com" - ); + let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings); + + assert_eq!(target.request_url, "https://www.zhihu.com"); + assert_eq!(target.source, BootstrapTargetSource::Fallback); } #[test] - fn initial_request_url_falls_back_to_zhihu_origin_for_generated_article_publish_routes() { - let request = SubmitTaskRequest { - instruction: "在知乎自动发表一篇名称为人工智能技能大全".to_string(), - ..SubmitTaskRequest::default() - }; - - assert_eq!( - initial_request_url_for_submit_task(&request), - "https://www.zhihu.com" - ); - } - - #[test] - fn initial_request_url_falls_back_to_lineloss_origin_for_lineloss_instructions() { + fn deterministic_bootstrap_target_uses_plan_target_url() { let request = SubmitTaskRequest { instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(), ..SubmitTaskRequest::default() }; + let settings = service_test_settings(None, None); + + let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings); assert_eq!( - initial_request_url_for_submit_task(&request), - "http://20.76.57.61:18080" + target.request_url, + "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor" ); + assert_eq!(target.expected_domain.as_deref(), Some("20.76.57.61")); + assert_eq!(target.source, BootstrapTargetSource::DeterministicPlan); + } + + #[test] + fn skill_metadata_bootstrap_url_is_used_when_no_page_context_or_plan_exists() { + let request = SubmitTaskRequest { + instruction: "请采集 2026-03 的故障明细并返回结果".to_string(), + ..SubmitTaskRequest::default() + }; + let settings = service_test_settings( + Some(staged_skill_staging_dir()), + Some("fault-details-report.collect_fault_details"), + ); + + let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings); + + assert_eq!(target.request_url, "https://95598.sgcc.com.cn/"); + assert_eq!(target.expected_domain.as_deref(), Some("95598.sgcc.com.cn")); + assert_eq!(target.source, BootstrapTargetSource::SkillConfig); + } + + #[test] + fn malformed_skill_bootstrap_url_falls_back_to_about_blank() { + let request = SubmitTaskRequest { + instruction: "请采集 2026-03 的故障明细并返回结果".to_string(), + ..SubmitTaskRequest::default() + }; + let skills_dir = temp_direct_submit_skill_root("not-a-valid-absolute-url"); + let settings = service_test_settings( + Some(skills_dir.clone()), + Some("fault-details-report.collect_fault_details"), + ); + + let target = resolve_submit_bootstrap_target(&request, Path::new("."), &settings); + + assert_eq!(target.request_url, "about:blank"); + assert_eq!(target.expected_domain, None); + assert_eq!(target.source, BootstrapTargetSource::Fallback); + + let _ = fs::remove_dir_all(skills_dir); + } + + #[test] + fn bootstrap_target_precedence_matrix_covers_page_context_deterministic_skill_and_fallback() { + let page_request = SubmitTaskRequest { + instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(), + page_url: Some(" https://already-open.example.com/page ".to_string()), + ..SubmitTaskRequest::default() + }; + let page_settings = service_test_settings( + Some(staged_skill_staging_dir()), + Some("fault-details-report.collect_fault_details"), + ); + let page_target = + resolve_submit_bootstrap_target(&page_request, Path::new("."), &page_settings); + assert_eq!(page_target.request_url, "https://already-open.example.com/page"); + assert_eq!(page_target.source, BootstrapTargetSource::PageContext); + + let deterministic_request = SubmitTaskRequest { + instruction: "兰州公司 台区线损大数据 月累计线损率统计分析。。。".to_string(), + ..SubmitTaskRequest::default() + }; + let deterministic_settings = service_test_settings( + Some(staged_skill_staging_dir()), + Some("fault-details-report.collect_fault_details"), + ); + let deterministic_target = resolve_submit_bootstrap_target( + &deterministic_request, + Path::new("."), + &deterministic_settings, + ); + assert_eq!( + deterministic_target.request_url, + "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor" + ); + assert_eq!( + deterministic_target.source, + BootstrapTargetSource::DeterministicPlan + ); + + let skill_request = SubmitTaskRequest { + instruction: "请采集 2026-03 的故障明细并返回结果".to_string(), + ..SubmitTaskRequest::default() + }; + let skill_settings = service_test_settings( + Some(staged_skill_staging_dir()), + Some("fault-details-report.collect_fault_details"), + ); + let skill_target = + resolve_submit_bootstrap_target(&skill_request, Path::new("."), &skill_settings); + assert_eq!(skill_target.request_url, "https://95598.sgcc.com.cn/"); + assert_eq!(skill_target.source, BootstrapTargetSource::SkillConfig); + + let fallback_request = SubmitTaskRequest { + instruction: "完全不相关的普通问题".to_string(), + ..SubmitTaskRequest::default() + }; + let fallback_settings = service_test_settings(None, None); + let fallback_target = + resolve_submit_bootstrap_target(&fallback_request, Path::new("."), &fallback_settings); + assert_eq!(fallback_target.request_url, "about:blank"); + assert_eq!(fallback_target.source, BootstrapTargetSource::Fallback); } #[test] diff --git a/tests/service_ws_session_test.rs b/tests/service_ws_session_test.rs index 7931f97..9878585 100644 --- a/tests/service_ws_session_test.rs +++ b/tests/service_ws_session_test.rs @@ -194,12 +194,34 @@ fn start_callback_host_hotlist_browser_server( .send(CallbackHostBrowserEvent::BrowserFrame(first_action.clone())) .unwrap(); - let Some(values) = first_action.as_array() else { + let second_action = match websocket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(&text).unwrap(), + other => panic!("expected second browser action frame, got {other:?}"), + }; + event_tx + .send(CallbackHostBrowserEvent::BrowserFrame(second_action.clone())) + .unwrap(); + + let Some(close_values) = first_action.as_array() else { + websocket.close(None).ok(); + return; + }; + let is_helper_close = close_values.len() >= 3 + && close_values[1] == json!("sgHideBrowerserClosePage") + && close_values[2] + .as_str() + .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html")); + if !is_helper_close { + websocket.close(None).ok(); + return; + } + + let Some(values) = second_action.as_array() else { websocket.close(None).ok(); return; }; let is_helper_open = values.len() >= 3 - && values[1] == json!("sgBrowerserOpenPage") + && values[1] == json!("sgHideBrowerserOpenPage") && values[2] .as_str() .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html")); @@ -873,6 +895,7 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() { browser_server.join().unwrap(); let register = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let bootstrap_close = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let bootstrap = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let pre_ready = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let open_page = event_rx.recv_timeout(Duration::from_secs(4)).unwrap(); @@ -902,12 +925,22 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() { }; assert_eq!(register, json!({ "type": "register", "role": "web" })); + let bootstrap_close = match bootstrap_close { + CallbackHostBrowserEvent::BrowserFrame(value) => value, + other => panic!("expected helper close frame, got {other:?}"), + }; + assert_eq!(bootstrap_close[0], json!("https://www.zhihu.com")); + assert_eq!(bootstrap_close[1], json!("sgHideBrowerserClosePage")); + assert!(bootstrap_close[2] + .as_str() + .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"))); + let bootstrap = match bootstrap { CallbackHostBrowserEvent::BrowserFrame(value) => value, other => panic!("expected helper bootstrap frame, got {other:?}"), }; assert_eq!(bootstrap[0], json!("https://www.zhihu.com")); - assert_eq!(bootstrap[1], json!("sgBrowerserOpenPage")); + assert_eq!(bootstrap[1], json!("sgHideBrowerserOpenPage")); assert!(bootstrap[2] .as_str() .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"))); @@ -1046,6 +1079,7 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() { browser_server.join().unwrap(); let register = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let bootstrap_close = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let bootstrap = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let pre_ready = event_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let open_page = event_rx.recv_timeout(Duration::from_secs(4)).unwrap(); @@ -1075,12 +1109,22 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() { }; assert_eq!(register, json!({ "type": "register", "role": "web" })); + let bootstrap_close = match bootstrap_close { + CallbackHostBrowserEvent::BrowserFrame(value) => value, + other => panic!("expected helper close frame, got {other:?}"), + }; + assert_eq!(bootstrap_close[0], json!("https://www.zhihu.com")); + assert_eq!(bootstrap_close[1], json!("sgHideBrowerserClosePage")); + assert!(bootstrap_close[2] + .as_str() + .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"))); + let bootstrap = match bootstrap { CallbackHostBrowserEvent::BrowserFrame(value) => value, other => panic!("expected helper bootstrap frame, got {other:?}"), }; assert_eq!(bootstrap[0], json!("https://www.zhihu.com")); - assert_eq!(bootstrap[1], json!("sgBrowerserOpenPage")); + assert_eq!(bootstrap[1], json!("sgHideBrowerserOpenPage")); assert!(bootstrap[2] .as_str() .is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html")));