Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- MockLlmDriver 基础设施 (zclaw-runtime/src/test_util.rs) - 经验闭环 E-01~06: 累积/溢出/反序列化/跨行业/并发/阈值 - Embedding 管道 EM-01~08: 路由/降级/维度不匹配/空查询/CJK/LLM Fallback/热更新 - Skill 执行 SK-01~03: 工具传递/纯 Prompt/锁竞争
223 lines
6.8 KiB
Rust
223 lines
6.8 KiB
Rust
//! Tool-enabled skill execution tests (SK-01 ~ SK-03)
|
|
//!
|
|
//! Validates that skills with tool declarations actually pass tools to the LLM,
|
|
//! skills without tools use pure prompt mode, and lock poisoning is handled gracefully.
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use serde_json::{json, Value};
|
|
use zclaw_skills::{
|
|
PromptOnlySkill, LlmCompleter, Skill, SkillCompletion, SkillContext,
|
|
SkillManifest, SkillMode, SkillToolCall, SkillRegistry,
|
|
};
|
|
use zclaw_types::id::SkillId;
|
|
use zclaw_types::tool::ToolDefinition;
|
|
|
|
fn make_tool_manifest(id: &str, tools: Vec<&str>) -> SkillManifest {
|
|
SkillManifest {
|
|
id: SkillId::new(id),
|
|
name: id.to_string(),
|
|
description: format!("{} test skill", id),
|
|
version: "1.0.0".to_string(),
|
|
mode: SkillMode::PromptOnly,
|
|
tools: tools.into_iter().map(String::from).collect(),
|
|
enabled: true,
|
|
author: None,
|
|
capabilities: Vec::new(),
|
|
input_schema: None,
|
|
output_schema: None,
|
|
tags: Vec::new(),
|
|
category: None,
|
|
triggers: Vec::new(),
|
|
body: None,
|
|
}
|
|
}
|
|
|
|
/// Mock LLM completer that records calls and returns preset responses.
|
|
struct MockCompleter {
|
|
response_text: String,
|
|
tool_calls: Vec<SkillToolCall>,
|
|
calls: std::sync::Mutex<Vec<String>>,
|
|
tools_received: std::sync::Mutex<Vec<Vec<ToolDefinition>>>,
|
|
}
|
|
|
|
impl MockCompleter {
|
|
fn new(text: &str) -> Self {
|
|
Self {
|
|
response_text: text.to_string(),
|
|
tool_calls: Vec::new(),
|
|
calls: std::sync::Mutex::new(Vec::new()),
|
|
tools_received: std::sync::Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn with_tool_call(mut self, name: &str, input: Value) -> Self {
|
|
self.tool_calls.push(SkillToolCall {
|
|
id: format!("call_{}", name),
|
|
name: name.to_string(),
|
|
input,
|
|
});
|
|
self
|
|
}
|
|
|
|
fn call_count(&self) -> usize {
|
|
self.calls.lock().unwrap().len()
|
|
}
|
|
|
|
fn last_tools(&self) -> Vec<ToolDefinition> {
|
|
self.tools_received
|
|
.lock()
|
|
.unwrap()
|
|
.last()
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl LlmCompleter for MockCompleter {
|
|
fn complete(
|
|
&self,
|
|
prompt: &str,
|
|
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + '_>> {
|
|
self.calls.lock().unwrap().push(prompt.to_string());
|
|
let text = self.response_text.clone();
|
|
Box::pin(async move { Ok(text) })
|
|
}
|
|
|
|
fn complete_with_tools(
|
|
&self,
|
|
prompt: &str,
|
|
_system_prompt: Option<&str>,
|
|
tools: Vec<ToolDefinition>,
|
|
) -> Pin<Box<dyn Future<Output = Result<SkillCompletion, String>> + Send + '_>> {
|
|
self.calls.lock().unwrap().push(prompt.to_string());
|
|
self.tools_received.lock().unwrap().push(tools);
|
|
let text = self.response_text.clone();
|
|
let tool_calls = self.tool_calls.clone();
|
|
Box::pin(async move {
|
|
Ok(SkillCompletion { text, tool_calls })
|
|
})
|
|
}
|
|
}
|
|
|
|
/// SK-01: Skill with tool declarations passes tools to LLM via complete_with_tools.
|
|
#[tokio::test]
|
|
async fn sk01_skill_with_tools_calls_complete_with_tools() {
|
|
let completer = Arc::new(MockCompleter::new("Research completed").with_tool_call(
|
|
"web_fetch",
|
|
json!({"url": "https://example.com"}),
|
|
));
|
|
|
|
let manifest = make_tool_manifest("web-researcher", vec!["web_fetch", "execute_skill"]);
|
|
|
|
let tool_defs = vec![
|
|
ToolDefinition::new("web_fetch", "Fetch a URL", json!({"type": "object"})),
|
|
ToolDefinition::new("execute_skill", "Execute another skill", json!({"type": "object"})),
|
|
];
|
|
|
|
let ctx = SkillContext {
|
|
agent_id: "agent-1".into(),
|
|
session_id: "sess-1".into(),
|
|
llm: Some(completer.clone()),
|
|
tool_definitions: tool_defs.clone(),
|
|
..SkillContext::default()
|
|
};
|
|
|
|
let skill = PromptOnlySkill::new(
|
|
manifest.clone(),
|
|
"Research: {{input}}".to_string(),
|
|
);
|
|
let result = skill.execute(&ctx, json!("rust programming")).await;
|
|
|
|
assert!(result.is_ok(), "skill execution should succeed");
|
|
let skill_result = result.unwrap();
|
|
assert!(skill_result.success, "skill result should be successful");
|
|
|
|
// Verify LLM was called
|
|
assert_eq!(completer.call_count(), 1, "LLM should be called once");
|
|
|
|
// Verify tools were passed
|
|
let tools = completer.last_tools();
|
|
assert_eq!(tools.len(), 2, "both tools should be passed to LLM");
|
|
assert_eq!(tools[0].name, "web_fetch");
|
|
assert_eq!(tools[1].name, "execute_skill");
|
|
}
|
|
|
|
/// SK-02: Skill without tool declarations uses pure complete() call.
|
|
#[tokio::test]
|
|
async fn sk02_skill_without_tools_uses_pure_prompt() {
|
|
let completer = Arc::new(MockCompleter::new("Writing helper response"));
|
|
|
|
let manifest = make_tool_manifest("writing-helper", vec![]);
|
|
|
|
let ctx = SkillContext {
|
|
agent_id: "agent-1".into(),
|
|
session_id: "sess-1".into(),
|
|
llm: Some(completer.clone()),
|
|
tool_definitions: vec![],
|
|
..SkillContext::default()
|
|
};
|
|
|
|
let skill = PromptOnlySkill::new(
|
|
manifest,
|
|
"Help with: {{input}}".to_string(),
|
|
);
|
|
let result = skill.execute(&ctx, json!("write a summary")).await;
|
|
|
|
assert!(result.is_ok());
|
|
let skill_result = result.unwrap();
|
|
assert!(skill_result.success);
|
|
|
|
// Verify LLM was called (via complete(), not complete_with_tools)
|
|
assert_eq!(completer.call_count(), 1);
|
|
// No tools should have been received (complete path, not complete_with_tools)
|
|
assert!(
|
|
completer.last_tools().is_empty(),
|
|
"pure prompt should not pass tools"
|
|
);
|
|
}
|
|
|
|
/// SK-03: Skill execution degrades gracefully on lock poisoning.
|
|
/// Note: SkillRegistry uses std::sync::RwLock which can be poisoned.
|
|
/// This test verifies that registry operations handle the poisoned state.
|
|
#[tokio::test]
|
|
async fn sk03_registry_handles_lock_contention() {
|
|
let registry = Arc::new(SkillRegistry::new());
|
|
|
|
let manifest = make_tool_manifest("test-skill", vec![]);
|
|
|
|
// Register skill
|
|
registry
|
|
.register(
|
|
Arc::new(PromptOnlySkill::new(
|
|
manifest.clone(),
|
|
"Test: {{input}}".to_string(),
|
|
)),
|
|
manifest,
|
|
)
|
|
.await;
|
|
|
|
// Concurrent read and write should not panic
|
|
let r1 = registry.clone();
|
|
let r2 = registry.clone();
|
|
|
|
let h1 = tokio::spawn(async move {
|
|
for _ in 0..10 {
|
|
let _ = r1.list().await;
|
|
}
|
|
});
|
|
let h2 = tokio::spawn(async move {
|
|
for _ in 0..10 {
|
|
let _ = r2.list().await;
|
|
}
|
|
});
|
|
|
|
h1.await.unwrap();
|
|
h2.await.unwrap();
|
|
|
|
// Verify skill is still accessible
|
|
let skill = registry.get(&SkillId::new("test-skill")).await;
|
|
assert!(skill.is_some(), "skill should still be registered");
|
|
}
|