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 <noreply@anthropic.com>
This commit is contained in:
66
Cargo.lock
generated
66
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<T: Transport + 'static>(
|
||||
request: &SubmitTaskRequest,
|
||||
) -> Result<Arc<dyn BrowserBackend>, 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()),
|
||||
));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Option<DirectSubmitBootstrapMetadata>, 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::<DirectSubmitSkillManifest>(&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<DirectSubmitSkillManifestTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectSubmitSkillManifestTool {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
metadata: Option<DirectSubmitToolMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectSubmitToolMetadata {
|
||||
#[serde(default)]
|
||||
bootstrap_url: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_domain: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_bootstrap_metadata(
|
||||
bootstrap_url: Option<String>,
|
||||
expected_domain: Option<String>,
|
||||
) -> Option<DirectSubmitBootstrapMetadata> {
|
||||
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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String>,
|
||||
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<String> {
|
||||
@@ -457,12 +540,6 @@ fn derive_request_url_from_instruction(instruction: &str) -> Option<String> {
|
||||
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<PathBuf>,
|
||||
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]
|
||||
|
||||
@@ -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::<Value>(&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")));
|
||||
|
||||
Reference in New Issue
Block a user