feat: route staged scene skills through runtime
Add registry-driven scene routing and multi-root skill loading so fault-details and 95598 scene skills can be triggered from natural language while still running through the browser-backed runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use common::MockTransport;
|
||||
use serde_json::json;
|
||||
use sgclaw::browser::{BrowserBackend, PipeBrowserBackend};
|
||||
use sgclaw::compat::browser_script_skill_tool::BrowserScriptSkillTool;
|
||||
use sgclaw::compat::browser_script_skill_tool::{
|
||||
execute_browser_script_tool, BrowserScriptSkillTool,
|
||||
};
|
||||
use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing};
|
||||
use sgclaw::security::MacPolicy;
|
||||
use zeroclaw::skills::SkillTool;
|
||||
@@ -113,6 +115,125 @@ return {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_executes_packaged_script_via_eval() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(
|
||||
scripts_dir.join("collect_fault_details.js"),
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "故障明细",
|
||||
rows: [[args.period, "已完成"]]
|
||||
};
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
success: true,
|
||||
data: json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
aom_snapshot: vec![],
|
||||
timing: Timing {
|
||||
queue_ms: 1,
|
||||
exec_ms: 5,
|
||||
},
|
||||
}]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "collect_fault_details".to_string(),
|
||||
description: "Collect fault detail rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"expected_domain": "https://www.zhihu.com/hot",
|
||||
"period": "2026-04"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(result.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result.output).unwrap(),
|
||||
json!({
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
&sent[0],
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval
|
||||
&& security.expected_domain == "www.zhihu.com"
|
||||
&& params["script"].as_str().unwrap().contains("const args = {\"period\":\"2026-04\"};")
|
||||
&& params["script"].as_str().unwrap().contains("sheet_name")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_script_helper_requires_expected_domain() {
|
||||
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-missing-domain");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
fs::create_dir_all(&scripts_dir).unwrap();
|
||||
fs::write(scripts_dir.join("collect_fault_details.js"), "return { ok: true };\n").unwrap();
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
let backend: Arc<dyn BrowserBackend> = Arc::new(PipeBrowserBackend::from_inner(browser_tool));
|
||||
|
||||
let mut args = HashMap::new();
|
||||
args.insert("period".to_string(), "Target report period".to_string());
|
||||
let skill_tool = SkillTool {
|
||||
name: "collect_fault_details".to_string(),
|
||||
description: "Collect fault detail rows".to_string(),
|
||||
kind: "browser_script".to_string(),
|
||||
command: "scripts/collect_fault_details.js".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let result = execute_browser_script_tool(&skill_tool, &skill_dir, backend, json!({
|
||||
"period": "2026-04"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(
|
||||
result.error.as_deref(),
|
||||
Some("missing required field expected_domain")
|
||||
);
|
||||
assert!(transport.sent_messages().is_empty());
|
||||
}
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config, build_zeroclaw_config_from_settings,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir, zeroclaw_default_skills_dir,
|
||||
zeroclaw_workspace_dir,
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_scene_skills_dir_path, resolve_skills_dir,
|
||||
zeroclaw_default_skills_dir, zeroclaw_workspace_dir,
|
||||
};
|
||||
use sgclaw::config::{
|
||||
BrowserBackend, DeepSeekSettings, OfficeBackend, PlannerMode, SgClawSettings, SkillsPromptMode,
|
||||
@@ -47,7 +47,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://proxy.example.com/v1".to_string(),
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
|
||||
let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw"));
|
||||
@@ -66,7 +66,7 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() {
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skills_dir(Path::new("/var/lib/sgclaw"), &settings),
|
||||
zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))
|
||||
vec![zeroclaw_default_skills_dir(Path::new("/var/lib/sgclaw"))]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(first.api_key, "sk-first");
|
||||
assert_eq!(first.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(first.model, "deepseek-chat");
|
||||
assert_eq!(first.skills_dir, None);
|
||||
assert!(first.skills_dir.is_empty());
|
||||
|
||||
fs::write(
|
||||
&config_path,
|
||||
@@ -111,7 +111,7 @@ fn deepseek_settings_reload_from_browser_config_path_after_file_changes() {
|
||||
assert_eq!(second.api_key, "sk-second");
|
||||
assert_eq!(second.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(second.model, "deepseek-reasoner");
|
||||
assert_eq!(second.skills_dir, Some(root.join("skill_lib")));
|
||||
assert_eq!(second.skills_dir, vec![root.join("skill_lib")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,12 +122,12 @@ fn resolve_skills_dir_prefers_nested_skills_subdirectory_for_configured_repo_roo
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(root.join("skill_lib")),
|
||||
skills_dir: vec![root.join("skill_lib")],
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, root.join("skill_lib/skills"));
|
||||
assert_eq!(resolved, vec![root.join("skill_lib/skills")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,12 +139,41 @@ fn resolve_skills_dir_preserves_absolute_configured_skills_directory() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: Some(external_skills.clone()),
|
||||
skills_dir: vec![external_skills.clone()],
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, external_skills);
|
||||
assert_eq!(resolved, vec![external_skills]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skills_dir_uses_skills_child_for_external_staged_root() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-skills-{}", Uuid::new_v4()));
|
||||
let staged_root = root.join("external/skill_staging");
|
||||
fs::create_dir_all(staged_root.join("skills")).unwrap();
|
||||
fs::create_dir_all(staged_root.join("scenes")).unwrap();
|
||||
let settings = DeepSeekSettings {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: vec![staged_root.clone()],
|
||||
};
|
||||
|
||||
let resolved = resolve_skills_dir(&root, &settings);
|
||||
|
||||
assert_eq!(resolved, vec![staged_root.join("skills")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_scene_skills_dir_path_prefers_staged_skills_child_under_project_root() {
|
||||
let root = std::env::temp_dir().join(format!("sgclaw-scene-skills-{}", Uuid::new_v4()));
|
||||
let top_level_skills = root.join("project/skills");
|
||||
fs::create_dir_all(top_level_skills.join("skill_staging/skills")).unwrap();
|
||||
|
||||
let resolved = resolve_scene_skills_dir_path(top_level_skills.clone());
|
||||
|
||||
assert_eq!(resolved, top_level_skills.join("skill_staging/skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -153,7 +182,7 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -187,7 +216,7 @@ fn sgclaw_settings_load_new_runtime_fields_from_browser_config() {
|
||||
|
||||
assert_eq!(settings.runtime_profile, RuntimeProfile::GeneralAssistant);
|
||||
assert_eq!(settings.skills_prompt_mode, SkillsPromptMode::Full);
|
||||
assert_eq!(settings.skills_dir, Some(root.join("skill_lib")));
|
||||
assert_eq!(settings.skills_dir, vec![root.join("skill_lib")]);
|
||||
assert_eq!(config.skills.prompt_injection_mode, SkillsPromptMode::Full);
|
||||
}
|
||||
|
||||
@@ -251,7 +280,7 @@ fn browser_attached_config_uses_low_temperature_for_deterministic_execution() {
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ async fn compat_cron_adapter_creates_lists_and_runs_due_agent_jobs() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-cron");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -16,7 +16,7 @@ async fn compat_memory_adapter_uses_workspace_local_sqlite_backend() {
|
||||
api_key: "key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
let workspace_root = workspace_root("sgclaw-memory");
|
||||
let config = build_zeroclaw_config_from_settings(Path::new(&workspace_root), &settings);
|
||||
|
||||
@@ -118,6 +118,17 @@ fn real_skill_lib_root() -> PathBuf {
|
||||
.join("skill_lib")
|
||||
}
|
||||
|
||||
fn staged_skill_root() -> PathBuf {
|
||||
PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging")
|
||||
}
|
||||
|
||||
fn project_skills_root() -> PathBuf {
|
||||
staged_skill_root()
|
||||
.parent()
|
||||
.expect("staged skill root should have parent")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
fn success_browser_response(seq: u64, data: Value) -> BrowserMessage {
|
||||
BrowserMessage::Response {
|
||||
seq,
|
||||
@@ -412,7 +423,7 @@ fn compat_runtime_includes_default_workspace_skills_in_provider_request() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -697,7 +708,7 @@ fn handle_browser_message_routes_supported_instruction_to_compat_runtime_when_ll
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_emits_plan_preview_before_runtime_execution() {
|
||||
fn handle_browser_message_executes_without_legacy_plan_preview() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let first_response = json!({
|
||||
@@ -771,28 +782,21 @@ fn handle_browser_message_emits_plan_preview_before_runtime_execution() {
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let preview_index = sent
|
||||
.iter()
|
||||
.position(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "plan" && message.contains("navigate https://www.baidu.com")
|
||||
)
|
||||
})
|
||||
.expect("expected plan preview log entry");
|
||||
let navigate_index = sent
|
||||
.iter()
|
||||
.position(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.baidu.com"
|
||||
)
|
||||
})
|
||||
.expect("expected runtime navigate log entry");
|
||||
|
||||
assert!(preview_index < navigate_index);
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "plan" && message.contains("navigate https://www.baidu.com")
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message == "navigate https://www.baidu.com"
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -957,7 +961,7 @@ fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response {
|
||||
seq: 1,
|
||||
@@ -1056,7 +1060,7 @@ fn compat_runtime_does_not_forward_raw_aom_snapshot_back_to_provider() {
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
|
||||
let large_snapshot_marker = "snapshot-marker ".repeat(2048);
|
||||
@@ -1118,7 +1122,7 @@ fn compat_runtime_injects_browser_contract_and_page_context_into_provider_reques
|
||||
api_key: "deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
@@ -1194,7 +1198,7 @@ fn compat_runtime_can_complete_a_text_only_turn_without_browser_tool_calls() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::GeneralAssistant;
|
||||
@@ -1272,7 +1276,7 @@ fn compat_runtime_allows_read_skill_under_compact_mode_policy() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
@@ -1357,7 +1361,7 @@ top_n = "How many hotlist rows to extract."
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1464,7 +1468,7 @@ return {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1535,7 +1539,7 @@ fn zhihu_hotlist_browser_skill_flow_does_not_expose_shell_or_glob_tools() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Some(real_skill_lib_root()),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1589,7 +1593,7 @@ fn compat_runtime_browser_attached_profile_keeps_file_read_available_for_local_p
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1640,7 +1644,7 @@ fn browser_attached_export_flow_exposes_browser_and_office_tools_only() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Some(real_skill_lib_root()),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1695,7 +1699,7 @@ fn compat_runtime_allows_zhihu_hotlist_screen_export_tool_in_browser_profile() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Some(real_skill_lib_root()),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1777,7 +1781,7 @@ fn compat_runtime_logs_read_skill_usage_with_skill_name() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
@@ -1914,7 +1918,7 @@ fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuff
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Some(real_skill_lib_root()),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -1966,7 +1970,7 @@ fn browser_attached_publish_request_injects_confirmation_contract() {
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
Some(real_skill_lib_root()),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
@@ -2597,6 +2601,711 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_p
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_exposes_project_skills_and_staged_scene_skills_together() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已同时看到顶层与场景技能"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(project_skills_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "告诉我当前有哪些知乎和95598相关 skill".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.net/".to_string(),
|
||||
page_title: "Example Domain".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected loaded skills log entry");
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已同时看到顶层与场景技能"
|
||||
)
|
||||
}));
|
||||
assert!(loaded_skills_message.contains("zhihu-hotlist@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("zhihu-write@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("fault-details-report@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0"));
|
||||
assert!(first_request.contains("zhihu-hotlist"));
|
||||
assert!(first_request.contains("zhihu-write"));
|
||||
assert!(first_request.contains("fault-details-report"));
|
||||
assert!(first_request.contains("95598-repair-city-dispatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_finds_staged_scene_skill_under_project_skills_root() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(project_skills_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_browser_message_exposes_staged_95598_scene_skills_and_contract_on_agent_path() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已按95598场景进入通用代理路径"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
&base_url,
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["95598.example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列"
|
||||
.to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://95598.example.invalid/dispatch".to_string(),
|
||||
page_title: "95598抢修市指监测".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("expected loaded skills log entry");
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "compat_llm_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已按95598场景进入通用代理路径"
|
||||
)
|
||||
}));
|
||||
assert!(loaded_skills_message.contains("fault-details-report@0.1.0"));
|
||||
assert!(loaded_skills_message.contains("95598-repair-city-dispatch@0.1.0"));
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(first_request.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(first_request.contains("Current page URL: https://95598.example.invalid/dispatch"));
|
||||
assert!(first_request.contains("Current page title: 95598抢修市指监测"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_surface_disabled_fault_details_turn_uses_general_assistant_provider_path() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已走通用助手路径"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = workspace_root.join("sgclaw_config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"apiKey": "deepseek-test-key",
|
||||
"baseUrl": base_url,
|
||||
"model": "deepseek-chat",
|
||||
"runtimeProfile": "generalAssistant",
|
||||
"skillsDir": staged_skill_root(),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
let loaded_skills_message = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "info" && message.starts_with("loaded skills: ") =>
|
||||
{
|
||||
Some(message.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "compat_llm_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary == "已走通用助手路径"
|
||||
)
|
||||
}));
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(!first_request.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!first_request.contains("browser workflow, not a text-only task"));
|
||||
assert!(!first_request.contains("generic browser probing only after"));
|
||||
assert!(loaded_skills_message.is_none());
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_zhihu_hotlist_request_keeps_zhihu_contract_without_scene_injection() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "已收到知乎导出任务"
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]);
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"deepseek-test-key".to_string(),
|
||||
base_url,
|
||||
"deepseek-chat".to_string(),
|
||||
vec![real_skill_lib_root()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::BrowserAttached;
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
let summary = execute_task_with_sgclaw_settings(
|
||||
transport.as_ref(),
|
||||
browser_tool,
|
||||
"读取知乎热榜数据,并导出 excel 文件",
|
||||
&CompatTaskContext::default(),
|
||||
&workspace_root,
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
server_handle.join().unwrap();
|
||||
|
||||
let request_bodies = requests.lock().unwrap().clone();
|
||||
let first_request = request_bodies[0].to_string();
|
||||
|
||||
assert_eq!(summary, "已收到知乎导出任务");
|
||||
assert_eq!(request_bodies.len(), 1);
|
||||
assert!(first_request.contains("Zhihu hotlist execution contract"));
|
||||
assert!(first_request.contains("Export completion contract"));
|
||||
assert!(first_request.contains("openxml_office"));
|
||||
assert!(!first_request.contains("95598 repair city dispatch execution contract"));
|
||||
assert!(!first_request.contains("browser workflow, not a text-only task"));
|
||||
assert!(!first_request.contains("generic browser probing only after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_direct_browser_scene_matches_primary_orchestration_gate() {
|
||||
assert!(sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_direct_browser_scene_detects_direct_route() {
|
||||
use sgclaw::compat::workflow_executor::{detect_route, WorkflowRoute};
|
||||
|
||||
assert_eq!(
|
||||
detect_route(
|
||||
"导出故障明细",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账")
|
||||
),
|
||||
Some(WorkflowRoute::FaultDetailsReport)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_scene_metadata_keeps_unrelated_primary_routing_unchanged() {
|
||||
let registry = [sgclaw::runtime::SceneRegistryEntry {
|
||||
id: "unrelated-scene".to_string(),
|
||||
name: "无关场景".to_string(),
|
||||
summary: "与故障明细无关。".to_string(),
|
||||
tags: vec!["other".to_string()],
|
||||
inputs: vec!["period".to_string()],
|
||||
outputs: vec!["artifact".to_string()],
|
||||
skill_package: "unrelated-skill".to_string(),
|
||||
skill_tool: "run_other".to_string(),
|
||||
skill_artifact_type: "artifact".to_string(),
|
||||
dispatch_mode: sgclaw::runtime::DispatchMode::DirectBrowser,
|
||||
expected_domain: "other.example.invalid".to_string(),
|
||||
aliases: vec!["别的事情".to_string()],
|
||||
default_args: serde_json::Map::new(),
|
||||
}];
|
||||
|
||||
assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "别的事情").is_some());
|
||||
assert!(sgclaw::runtime::match_scene_instruction_in_registry(®istry, "导出故障明细").is_none());
|
||||
assert!(!sgclaw::compat::orchestration::should_use_primary_orchestration(
|
||||
"帮我汇总今天待办",
|
||||
Some("https://example.invalid/workbench"),
|
||||
Some("业务台账"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_returns_clear_failure_when_period_cannot_be_derived() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
zhihu_test_policy(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::LogEntry { level, message }
|
||||
if level == "mode" && message == "zeroclaw_process_message_primary"
|
||||
)
|
||||
}));
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if !*success && summary.contains("period") && summary.contains("无法")
|
||||
)
|
||||
}));
|
||||
assert!(!sent.iter().any(|message| {
|
||||
matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_uses_current_page_host_as_expected_domain() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_uses_packaged_browser_script_from_configured_skills_root() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let staged_root = workspace_root.join("custom_fault_details_staging");
|
||||
let custom_skills_dir = staged_root.join("skills");
|
||||
let skill_dir = write_skill_manifest_package(
|
||||
&custom_skills_dir,
|
||||
"fault-details-report",
|
||||
r#"
|
||||
[skill]
|
||||
name = "fault-details-report"
|
||||
description = "Collect fault detail rows via a packaged browser script."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "collect_fault_details"
|
||||
description = "Collect fault detail rows for the target period."
|
||||
kind = "browser_script"
|
||||
command = "scripts/custom_fault_details.js"
|
||||
|
||||
[tools.args]
|
||||
period = "Target report period."
|
||||
"#,
|
||||
);
|
||||
write_skill_script(
|
||||
&skill_dir,
|
||||
"scripts/custom_fault_details.js",
|
||||
r#"
|
||||
return {
|
||||
sheet_name: "故障明细",
|
||||
rows: [[args.period || "unknown", "CUSTOM_FAULT_DETAILS_MARKER"]],
|
||||
marker: "CUSTOM_FAULT_DETAILS_MARKER"
|
||||
};
|
||||
"#,
|
||||
);
|
||||
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_root.to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
assert!(script.contains("CUSTOM_FAULT_DETAILS_MARKER"));
|
||||
assert!(!script.contains("collect_fault_details.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fault_details_route_executes_browser_script_eval_when_period_is_derived() {
|
||||
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
let workspace_root = temp_workspace_root();
|
||||
let config_path = write_deepseek_config_with_skills_dir(
|
||||
&workspace_root,
|
||||
"deepseek-test-key",
|
||||
"http://127.0.0.1:9",
|
||||
"deepseek-chat",
|
||||
Some(staged_skill_root().to_str().unwrap()),
|
||||
);
|
||||
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
|
||||
|
||||
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
|
||||
1,
|
||||
json!({
|
||||
"text": {
|
||||
"sheet_name": "故障明细",
|
||||
"rows": [["2026-04", "已完成"]]
|
||||
}
|
||||
}),
|
||||
)]));
|
||||
let browser_tool = BrowserPipeTool::new(
|
||||
transport.clone(),
|
||||
policy_for_domains(&["example.invalid"]),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
)
|
||||
.with_response_timeout(Duration::from_secs(1));
|
||||
|
||||
handle_browser_message_with_context(
|
||||
transport.as_ref(),
|
||||
&browser_tool,
|
||||
&runtime_context,
|
||||
BrowserMessage::SubmitTask {
|
||||
instruction: "导出 2026-04 故障明细".to_string(),
|
||||
conversation_id: String::new(),
|
||||
messages: vec![],
|
||||
page_url: "https://example.invalid/workbench".to_string(),
|
||||
page_title: "业务台账".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.sent_messages();
|
||||
assert!(sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
AgentMessage::TaskComplete { success, summary }
|
||||
if *success && summary.contains("sheet_name") && summary.contains("故障明细")
|
||||
)
|
||||
}));
|
||||
let eval_command = sent.iter().find_map(|message| match message {
|
||||
AgentMessage::Command {
|
||||
action,
|
||||
params,
|
||||
security,
|
||||
..
|
||||
} if action == &Action::Eval => Some((params.clone(), security.expected_domain.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let (params, expected_domain) = eval_command.expect("direct route should call browser eval");
|
||||
assert_eq!(expected_domain, "example.invalid");
|
||||
let script = params["script"].as_str().unwrap_or_default();
|
||||
assert!(script.contains("const args = {\"period\":\"2026-04\"};"));
|
||||
assert!(script.contains("sheet_name") || script.contains("return JSON.stringify") || script.contains("rows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhihu_generated_auto_publish_matches_primary_orchestration_gate() {
|
||||
assert!(
|
||||
|
||||
@@ -21,7 +21,7 @@ fn deepseek_settings_load_defaults_from_env() {
|
||||
assert_eq!(settings.api_key, "test-key");
|
||||
assert_eq!(settings.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(settings.model, "deepseek-chat");
|
||||
assert_eq!(settings.skills_dir, None);
|
||||
assert!(settings.skills_dir.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +30,7 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() {
|
||||
api_key: "test-key".to_string(),
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
skills_dir: None,
|
||||
skills_dir: Vec::new(),
|
||||
});
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sgclaw::compat::config_adapter::{
|
||||
build_zeroclaw_config_from_sgclaw_settings, resolve_skills_dir_from_sgclaw_settings,
|
||||
};
|
||||
use sgclaw::config::{BrowserBackend, OfficeBackend, PlannerMode, SgClawSettings};
|
||||
use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_skill_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-runtime-profile-skills-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(root.join("skills")).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn write_browser_script_skill(skill_root: &std::path::Path, skill_name: &str) {
|
||||
let skill_dir = skill_root.join("skills").join(skill_name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[skill]
|
||||
name = "{skill_name}"
|
||||
description = "Browser-only test skill."
|
||||
version = "0.1.0"
|
||||
|
||||
[[tools]]
|
||||
name = "run"
|
||||
description = "Run browser-only script."
|
||||
kind = "browser_script"
|
||||
command = "scripts/run.js"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(skill_dir.join("scripts")).unwrap();
|
||||
fs::write(skill_dir.join("scripts/run.js"), "return { ok: true };\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_skills_excludes_browser_script_tools_when_browser_surface_is_unavailable() {
|
||||
let workspace_root = std::env::temp_dir().join(format!(
|
||||
"sgclaw-runtime-profile-workspace-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
let skill_root = temp_skill_root();
|
||||
write_browser_script_skill(&skill_root, "fault-details-report");
|
||||
|
||||
let mut settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
vec![skill_root.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
settings.runtime_profile = RuntimeProfile::GeneralAssistant;
|
||||
let config = build_zeroclaw_config_from_sgclaw_settings(&workspace_root, &settings);
|
||||
let skills_dir = resolve_skills_dir_from_sgclaw_settings(&workspace_root, &settings);
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant);
|
||||
|
||||
let loaded_skills = engine.loaded_skills(&config, &skills_dir);
|
||||
|
||||
assert!(loaded_skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_profile_exposes_browser_surface_without_becoming_browser_only() {
|
||||
@@ -56,13 +124,61 @@ fn browser_attached_publish_prompt_requires_explicit_confirmation_before_clickin
|
||||
assert!(instruction.contains("stop after the confirmation request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_95598_scene_prompt_requires_scene_tool_before_generic_browser_probing() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(instruction.contains("generic browser probing only after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_attached_unrelated_task_does_not_receive_95598_scene_contract() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"帮我总结今天的会议纪要",
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(!instruction.contains("generic browser probing only after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_assistant_95598_scene_prompt_does_not_receive_browser_scene_contract() {
|
||||
let engine = RuntimeEngine::new(RuntimeProfile::GeneralAssistant);
|
||||
|
||||
let instruction = engine.build_instruction(
|
||||
"请处理95598-repair-city-dispatch场景,查看抢修市指派单并汇总当前队列",
|
||||
Some("https://95598.example.invalid/dispatch"),
|
||||
Some("95598抢修市指监测"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(!instruction.contains("95598-repair-city-dispatch.collect_repair_orders"));
|
||||
assert!(!instruction.contains("browser workflow, not a text-only task"));
|
||||
assert!(!instruction.contains("generic browser probing only after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_default_to_plan_first_superrpa_and_openxml_backends() {
|
||||
let settings = SgClawSettings::from_legacy_deepseek_fields(
|
||||
"sk-test".to_string(),
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
223
tests/scene_registry_test.rs
Normal file
223
tests/scene_registry_test.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sgclaw::runtime::{
|
||||
load_scene_registry_from_root, match_scene_instruction_in_registry, DispatchMode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn scene_registry_loads_first_slice_dispatch_policies() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let fault_details = registry
|
||||
.iter()
|
||||
.find(|entry| entry.id == "fault-details-report")
|
||||
.expect("fault-details-report scene should load");
|
||||
assert_eq!(fault_details.dispatch_mode, DispatchMode::DirectBrowser);
|
||||
assert_eq!(fault_details.expected_domain, "sgcc.example.invalid");
|
||||
assert_eq!(fault_details.skill_package, "fault-details-report");
|
||||
assert_eq!(fault_details.skill_tool, "collect_fault_details");
|
||||
|
||||
let repair_dispatch = registry
|
||||
.iter()
|
||||
.find(|entry| entry.id == "95598-repair-city-dispatch")
|
||||
.expect("95598-repair-city-dispatch scene should load");
|
||||
assert_eq!(repair_dispatch.dispatch_mode, DispatchMode::AgentBrowser);
|
||||
assert_eq!(repair_dispatch.skill_package, "95598-repair-city-dispatch");
|
||||
assert_eq!(repair_dispatch.skill_tool, "collect_repair_orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_fault_details_natural_language_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched =
|
||||
match_scene_instruction_in_registry(®istry, "请帮我导出故障明细").expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "fault-details-report");
|
||||
assert_eq!(matched.dispatch_mode, DispatchMode::DirectBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_city_dispatch_natural_language_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched = match_scene_instruction_in_registry(®istry, "帮我看一下95598抢修市指监测")
|
||||
.expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "95598-repair-city-dispatch");
|
||||
assert_eq!(matched.dispatch_mode, DispatchMode::AgentBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_matches_rephrased_instruction_via_alias_terms() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
let matched = match_scene_instruction_in_registry(®istry, "想看市指那边的95598抢修队列")
|
||||
.expect("scene should match");
|
||||
|
||||
assert_eq!(matched.id, "95598-repair-city-dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_returns_none_for_unrelated_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert!(match_scene_instruction_in_registry(®istry, "今天上海天气怎么样").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_ignores_missing_or_broken_scene_files() {
|
||||
let root = TempSceneRoot::new();
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "fault-details-report",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene("95598-repair-city-dispatch", "{ broken json");
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry[0].id, "fault-details-report");
|
||||
assert_eq!(registry[0].dispatch_mode, DispatchMode::DirectBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_ignores_mismatched_scene_metadata_id() {
|
||||
let root = TempSceneRoot::new();
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "wrong-scene-id",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene(
|
||||
"95598-repair-city-dispatch",
|
||||
r#"{
|
||||
"id": "95598-repair-city-dispatch",
|
||||
"name": "95598抢修市指监测",
|
||||
"summary": "采集95598抢修市指监测列表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["repair-orders"],
|
||||
"tags": ["95598", "repair", "dispatch"],
|
||||
"skill": {
|
||||
"package": "95598-repair-city-dispatch",
|
||||
"tool": "collect_repair_orders",
|
||||
"artifact_type": "repair-orders"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry[0].id, "95598-repair-city-dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_registry_returns_none_for_ambiguous_instruction() {
|
||||
let root = TempSceneRoot::new();
|
||||
write_first_slice_scenes(&root);
|
||||
let registry = load_scene_registry_from_root(root.path());
|
||||
|
||||
assert!(
|
||||
match_scene_instruction_in_registry(®istry, "请同时处理导出故障明细和95598抢修市指监测").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
struct TempSceneRoot {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl TempSceneRoot {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("scene-registry-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join("scenes")).expect("temp scene root should be created");
|
||||
Self { root }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
fn write_scene(&self, scene_id: &str, contents: &str) {
|
||||
let scene_dir = self.root.join("scenes").join(scene_id);
|
||||
fs::create_dir_all(&scene_dir).expect("scene directory should be created");
|
||||
fs::write(scene_dir.join("scene.json"), contents).expect("scene file should be written");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_first_slice_scenes(root: &TempSceneRoot) {
|
||||
root.write_scene(
|
||||
"fault-details-report",
|
||||
r#"{
|
||||
"id": "fault-details-report",
|
||||
"name": "故障明细",
|
||||
"summary": "查询故障明细行并生成结构化报表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["report-artifact"],
|
||||
"tags": ["fault", "report"],
|
||||
"skill": {
|
||||
"package": "fault-details-report",
|
||||
"tool": "collect_fault_details",
|
||||
"artifact_type": "report-artifact"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
root.write_scene(
|
||||
"95598-repair-city-dispatch",
|
||||
r#"{
|
||||
"id": "95598-repair-city-dispatch",
|
||||
"name": "95598抢修市指监测",
|
||||
"summary": "采集95598抢修市指监测列表。",
|
||||
"inputs": ["period"],
|
||||
"outputs": ["repair-orders"],
|
||||
"tags": ["95598", "repair", "dispatch"],
|
||||
"skill": {
|
||||
"package": "95598-repair-city-dispatch",
|
||||
"tool": "collect_repair_orders",
|
||||
"artifact_type": "repair-orders"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
}
|
||||
|
||||
impl Drop for TempSceneRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user