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:
木炎
2026-04-07 16:17:17 +08:00
parent bdf8e12246
commit 96c3bf1dee
21 changed files with 2846 additions and 240 deletions

View File

@@ -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)

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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(&registry, "别的事情").is_some());
assert!(sgclaw::runtime::match_scene_instruction_in_registry(&registry, "导出故障明细").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!(

View File

@@ -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 {

View File

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

View 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(&registry, "请帮我导出故障明细").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(&registry, "帮我看一下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(&registry, "想看市指那边的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(&registry, "今天上海天气怎么样").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(&registry, "请同时处理导出故障明细和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);
}
}

View File

@@ -1,5 +1,7 @@
mod common;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;