Compare commits
9 Commits
chore/sqlx
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee68fa763 | ||
|
|
891d972e20 | ||
|
|
e12766794b | ||
|
|
d9f8850083 | ||
|
|
0bd50aad8c | ||
|
|
4ee587d070 | ||
|
|
8b1b08be82 | ||
|
|
beeb529d8f | ||
|
|
226beb708b |
55
crates/zclaw-protocols/tests/mcp_transport_tests.rs
Normal file
55
crates/zclaw-protocols/tests/mcp_transport_tests.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Tests for MCP Transport configuration (McpServerConfig)
|
||||||
|
//!
|
||||||
|
//! These tests cover McpServerConfig builder methods without spawning processes.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use zclaw_protocols::McpServerConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npx_config_creates_correct_command() {
|
||||||
|
let config = McpServerConfig::npx("@modelcontextprotocol/server-memory");
|
||||||
|
assert_eq!(config.command, "npx");
|
||||||
|
assert_eq!(config.args, vec!["-y", "@modelcontextprotocol/server-memory"]);
|
||||||
|
assert!(config.env.is_empty());
|
||||||
|
assert!(config.cwd.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_config_creates_correct_command() {
|
||||||
|
let config = McpServerConfig::node("/path/to/server.js");
|
||||||
|
assert_eq!(config.command, "node");
|
||||||
|
assert_eq!(config.args, vec!["/path/to/server.js"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn python_config_creates_correct_command() {
|
||||||
|
let config = McpServerConfig::python("mcp_server.py");
|
||||||
|
assert_eq!(config.command, "python");
|
||||||
|
assert_eq!(config.args, vec!["mcp_server.py"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_adds_variables() {
|
||||||
|
let config = McpServerConfig::node("server.js")
|
||||||
|
.env("API_KEY", "secret123")
|
||||||
|
.env("DEBUG", "true");
|
||||||
|
assert_eq!(config.env.get("API_KEY").unwrap(), "secret123");
|
||||||
|
assert_eq!(config.env.get("DEBUG").unwrap(), "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cwd_sets_working_directory() {
|
||||||
|
let config = McpServerConfig::node("server.js").cwd("/tmp/work");
|
||||||
|
assert_eq!(config.cwd.unwrap(), "/tmp/work");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_builder_pattern() {
|
||||||
|
let config = McpServerConfig::npx("@scope/server")
|
||||||
|
.env("PORT", "3000")
|
||||||
|
.cwd("/app");
|
||||||
|
assert_eq!(config.command, "npx");
|
||||||
|
assert_eq!(config.args.len(), 2);
|
||||||
|
assert_eq!(config.env.len(), 1);
|
||||||
|
assert_eq!(config.cwd.unwrap(), "/app");
|
||||||
|
}
|
||||||
186
crates/zclaw-protocols/tests/mcp_types_domain_tests.rs
Normal file
186
crates/zclaw-protocols/tests/mcp_types_domain_tests.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//! Tests for MCP domain types (mcp.rs) — McpTool, McpContent, McpResource, etc.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use zclaw_protocols::*;
|
||||||
|
|
||||||
|
// === McpTool ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_roundtrip() {
|
||||||
|
let tool = McpTool {
|
||||||
|
name: "search".to_string(),
|
||||||
|
description: "Search documents".to_string(),
|
||||||
|
input_schema: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&tool).unwrap();
|
||||||
|
let parsed: McpTool = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.name, "search");
|
||||||
|
assert_eq!(parsed.description, "Search documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_empty_description() {
|
||||||
|
let tool = McpTool {
|
||||||
|
name: "ping".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
input_schema: serde_json::json!({}),
|
||||||
|
};
|
||||||
|
let parsed: McpTool = serde_json::from_str(&serde_json::to_string(&tool).unwrap()).unwrap();
|
||||||
|
assert!(parsed.description.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpContent ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_content_text_roundtrip() {
|
||||||
|
let content = McpContent::Text { text: "hello".to_string() };
|
||||||
|
let json = serde_json::to_string(&content).unwrap();
|
||||||
|
let parsed: McpContent = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed {
|
||||||
|
McpContent::Text { text } => assert_eq!(text, "hello"),
|
||||||
|
_ => panic!("Expected Text"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_content_image_roundtrip() {
|
||||||
|
let content = McpContent::Image {
|
||||||
|
data: "base64==".to_string(),
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&content).unwrap();
|
||||||
|
let parsed: McpContent = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed {
|
||||||
|
McpContent::Image { data, mime_type } => {
|
||||||
|
assert_eq!(data, "base64==");
|
||||||
|
assert_eq!(mime_type, "image/png");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Image"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_content_resource_roundtrip() {
|
||||||
|
let content = McpContent::Resource {
|
||||||
|
resource: McpResourceContent {
|
||||||
|
uri: "file:///test.txt".to_string(),
|
||||||
|
mime_type: Some("text/plain".to_string()),
|
||||||
|
text: Some("content".to_string()),
|
||||||
|
blob: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&content).unwrap();
|
||||||
|
let parsed: McpContent = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed {
|
||||||
|
McpContent::Resource { resource } => {
|
||||||
|
assert_eq!(resource.uri, "file:///test.txt");
|
||||||
|
assert_eq!(resource.text.unwrap(), "content");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Resource"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpToolCallRequest ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_call_request_serialization() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("query".to_string(), serde_json::json!("test"));
|
||||||
|
let req = McpToolCallRequest {
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: args,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("\"name\":\"search\""));
|
||||||
|
assert!(json.contains("\"query\":\"test\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpToolCallResponse ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_call_response_parse_success() {
|
||||||
|
let json = r#"{"content":[{"type":"text","text":"found 3 results"}],"is_error":false}"#;
|
||||||
|
let resp: McpToolCallResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!resp.is_error);
|
||||||
|
assert_eq!(resp.content.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_tool_call_response_parse_error() {
|
||||||
|
let json = r#"{"content":[{"type":"text","text":"tool not found"}],"is_error":true}"#;
|
||||||
|
let resp: McpToolCallResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(resp.is_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpResource ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_resource_roundtrip() {
|
||||||
|
let res = McpResource {
|
||||||
|
uri: "file:///doc.md".to_string(),
|
||||||
|
name: "Documentation".to_string(),
|
||||||
|
description: Some("Project docs".to_string()),
|
||||||
|
mime_type: Some("text/markdown".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&res).unwrap();
|
||||||
|
let parsed: McpResource = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.uri, "file:///doc.md");
|
||||||
|
assert_eq!(parsed.description.unwrap(), "Project docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpPrompt ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_prompt_roundtrip() {
|
||||||
|
let prompt = McpPrompt {
|
||||||
|
name: "summarize".to_string(),
|
||||||
|
description: "Summarize text".to_string(),
|
||||||
|
arguments: vec![
|
||||||
|
McpPromptArgument {
|
||||||
|
name: "length".to_string(),
|
||||||
|
description: "Target length".to_string(),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&prompt).unwrap();
|
||||||
|
let parsed: McpPrompt = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.arguments.len(), 1);
|
||||||
|
assert!(!parsed.arguments[0].required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpServerInfo ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_server_info_roundtrip() {
|
||||||
|
let info = McpServerInfo {
|
||||||
|
name: "test-mcp".to_string(),
|
||||||
|
version: "2.0.0".to_string(),
|
||||||
|
protocol_version: "2024-11-05".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
let parsed: McpServerInfo = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.name, "test-mcp");
|
||||||
|
assert_eq!(parsed.protocol_version, "2024-11-05");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpCapabilities ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_capabilities_default_empty() {
|
||||||
|
let caps = McpCapabilities::default();
|
||||||
|
assert!(caps.tools.is_none());
|
||||||
|
assert!(caps.resources.is_none());
|
||||||
|
assert!(caps.prompts.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_capabilities_with_tools() {
|
||||||
|
let caps = McpCapabilities {
|
||||||
|
tools: Some(McpToolCapabilities { list_changed: true }),
|
||||||
|
resources: None,
|
||||||
|
prompts: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&caps).unwrap();
|
||||||
|
assert!(json.contains("\"list_changed\":true"));
|
||||||
|
}
|
||||||
267
crates/zclaw-protocols/tests/mcp_types_tests.rs
Normal file
267
crates/zclaw-protocols/tests/mcp_types_tests.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
//! Tests for MCP JSON-RPC types (mcp_types.rs)
|
||||||
|
//!
|
||||||
|
//! Covers: serialization, deserialization, builder patterns, edge cases.
|
||||||
|
|
||||||
|
use serde_json;
|
||||||
|
use zclaw_protocols::*;
|
||||||
|
|
||||||
|
// === JsonRpcRequest ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_request_new_has_correct_defaults() {
|
||||||
|
let req = JsonRpcRequest::new(42, "tools/list");
|
||||||
|
assert_eq!(req.jsonrpc, "2.0");
|
||||||
|
assert_eq!(req.id, 42);
|
||||||
|
assert_eq!(req.method, "tools/list");
|
||||||
|
assert!(req.params.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_request_with_params() {
|
||||||
|
let req = JsonRpcRequest::new(1, "tools/call")
|
||||||
|
.with_params(serde_json::json!({"name": "search"}));
|
||||||
|
let serialized = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(serialized.contains("\"params\""));
|
||||||
|
assert!(serialized.contains("\"name\":\"search\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_request_skip_null_params() {
|
||||||
|
let req = JsonRpcRequest::new(1, "ping");
|
||||||
|
let serialized = serde_json::to_string(&req).unwrap();
|
||||||
|
// params is None, should be skipped
|
||||||
|
assert!(!serialized.contains("\"params\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JsonRpcResponse ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_response_parse_success() {
|
||||||
|
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
|
||||||
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(resp.id, 1);
|
||||||
|
assert!(resp.result.is_some());
|
||||||
|
assert!(resp.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_response_parse_error() {
|
||||||
|
let json = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||||
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(resp.id, 2);
|
||||||
|
assert!(resp.result.is_none());
|
||||||
|
let err = resp.error.unwrap();
|
||||||
|
assert_eq!(err.code, -32600);
|
||||||
|
assert_eq!(err.message, "Invalid Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonrpc_response_parse_error_with_data() {
|
||||||
|
let json = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Bad params","data":{"field":"uri"}}}"#;
|
||||||
|
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||||
|
let err = resp.error.unwrap();
|
||||||
|
assert!(err.data.is_some());
|
||||||
|
assert_eq!(err.data.unwrap()["field"], "uri");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === InitializeRequest ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialize_request_default() {
|
||||||
|
let req = InitializeRequest::default();
|
||||||
|
assert_eq!(req.protocol_version, "2024-11-05");
|
||||||
|
assert_eq!(req.client_info.name, "zclaw");
|
||||||
|
assert!(!req.client_info.version.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialize_request_serializes() {
|
||||||
|
let req = InitializeRequest::default();
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("\"protocol_version\":\"2024-11-05\""));
|
||||||
|
assert!(json.contains("\"client_info\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ServerCapabilities ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_capabilities_empty() {
|
||||||
|
let json = r#"{"protocol_version":"2024-11-05","capabilities":{},"server_info":{"name":"test","version":"1.0"}}"#;
|
||||||
|
let result: InitializeResult = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(result.capabilities.tools.is_none());
|
||||||
|
assert!(result.capabilities.resources.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_capabilities_with_tools() {
|
||||||
|
let json = r#"{"protocol_version":"2024-11-05","capabilities":{"tools":{"list_changed":true}},"server_info":{"name":"test","version":"1.0"}}"#;
|
||||||
|
let result: InitializeResult = serde_json::from_str(json).unwrap();
|
||||||
|
let tools = result.capabilities.tools.unwrap();
|
||||||
|
assert!(tools.list_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ContentBlock ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_block_text() {
|
||||||
|
let json = r#"{"type":"text","text":"hello world"}"#;
|
||||||
|
let block: ContentBlock = serde_json::from_str(json).unwrap();
|
||||||
|
match block {
|
||||||
|
ContentBlock::Text { text } => assert_eq!(text, "hello world"),
|
||||||
|
_ => panic!("Expected Text variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_block_image() {
|
||||||
|
let json = r#"{"type":"image","data":"base64data","mime_type":"image/png"}"#;
|
||||||
|
let block: ContentBlock = serde_json::from_str(json).unwrap();
|
||||||
|
match block {
|
||||||
|
ContentBlock::Image { data, mime_type } => {
|
||||||
|
assert_eq!(data, "base64data");
|
||||||
|
assert_eq!(mime_type, "image/png");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Image variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_block_resource() {
|
||||||
|
let json = r#"{"type":"resource","resource":{"uri":"file:///test.txt","text":"content"}}"#;
|
||||||
|
let block: ContentBlock = serde_json::from_str(json).unwrap();
|
||||||
|
match block {
|
||||||
|
ContentBlock::Resource { resource } => {
|
||||||
|
assert_eq!(resource.uri, "file:///test.txt");
|
||||||
|
assert_eq!(resource.text.unwrap(), "content");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Resource variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === CallToolResult ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_tool_result_parse() {
|
||||||
|
let json = r#"{"content":[{"type":"text","text":"result"}],"is_error":false}"#;
|
||||||
|
let result: CallToolResult = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!result.is_error);
|
||||||
|
assert_eq!(result.content.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_tool_result_error() {
|
||||||
|
let json = r#"{"content":[{"type":"text","text":"something went wrong"}],"is_error":true}"#;
|
||||||
|
let result: CallToolResult = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(result.is_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ListToolsResult ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_tools_result_with_cursor() {
|
||||||
|
let json = r#"{"tools":[{"name":"search","input_schema":{"type":"object"}}],"next_cursor":"abc123"}"#;
|
||||||
|
let result: ListToolsResult = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(result.tools.len(), 1);
|
||||||
|
assert_eq!(result.tools[0].name, "search");
|
||||||
|
assert_eq!(result.next_cursor.unwrap(), "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_tools_result_without_cursor() {
|
||||||
|
let json = r#"{"tools":[]}"#;
|
||||||
|
let result: ListToolsResult = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(result.tools.is_empty());
|
||||||
|
assert!(result.next_cursor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Resource types ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_parse_with_optional_fields() {
|
||||||
|
let json = r#"{"uri":"file:///doc.txt","name":"doc","description":"A doc","mime_type":"text/plain"}"#;
|
||||||
|
let res: Resource = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(res.uri, "file:///doc.txt");
|
||||||
|
assert_eq!(res.name, "doc");
|
||||||
|
assert_eq!(res.description.unwrap(), "A doc");
|
||||||
|
assert_eq!(res.mime_type.unwrap(), "text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_parse_minimal() {
|
||||||
|
let json = r#"{"uri":"file:///x","name":"x"}"#;
|
||||||
|
let res: Resource = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(res.description.is_none());
|
||||||
|
assert!(res.mime_type.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// === LoggingLevel ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_level_serialize_roundtrip() {
|
||||||
|
let levels = vec![
|
||||||
|
LoggingLevel::Debug,
|
||||||
|
LoggingLevel::Info,
|
||||||
|
LoggingLevel::Warning,
|
||||||
|
LoggingLevel::Error,
|
||||||
|
LoggingLevel::Critical,
|
||||||
|
LoggingLevel::Emergency,
|
||||||
|
];
|
||||||
|
for level in levels {
|
||||||
|
let json = serde_json::to_string(&level).unwrap();
|
||||||
|
let parsed: LoggingLevel = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(std::mem::discriminant(&level), std::mem::discriminant(&parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === InitializedNotification ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialized_notification_fields() {
|
||||||
|
let n = InitializedNotification::new();
|
||||||
|
assert_eq!(n.jsonrpc, "2.0");
|
||||||
|
assert_eq!(n.method, "notifications/initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialized_notification_serializes() {
|
||||||
|
let n = InitializedNotification::default();
|
||||||
|
let json = serde_json::to_string(&n).unwrap();
|
||||||
|
assert!(json.contains("\"notifications/initialized\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Prompt types ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_parse_with_arguments() {
|
||||||
|
let json = r#"{"name":"greet","description":"Greeting","arguments":[{"name":"lang","description":"Language","required":true}]}"#;
|
||||||
|
let prompt: Prompt = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(prompt.name, "greet");
|
||||||
|
assert_eq!(prompt.arguments.len(), 1);
|
||||||
|
assert!(prompt.arguments[0].required);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_message_parse() {
|
||||||
|
let json = r#"{"role":"user","content":{"type":"text","text":"hello"}}"#;
|
||||||
|
let msg: PromptMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(msg.role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === McpClientConfig ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_client_config_roundtrip() {
|
||||||
|
let config = McpClientConfig {
|
||||||
|
server_url: "http://localhost:3000".to_string(),
|
||||||
|
server_info: McpServerInfo {
|
||||||
|
name: "test-server".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
protocol_version: "2024-11-05".to_string(),
|
||||||
|
},
|
||||||
|
capabilities: McpCapabilities::default(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: McpClientConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.server_url, config.server_url);
|
||||||
|
assert_eq!(parsed.server_info.name, "test-server");
|
||||||
|
}
|
||||||
@@ -142,13 +142,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
|||||||
return Ok(selection);
|
return Ok(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有 Key 都超限或无 Key — 先检查是否存在活跃 Key
|
// 所有活跃 Key 都超限 — 先检查是否存在活跃 Key
|
||||||
let has_any_key: Option<(bool,)> = sqlx::query_as(
|
let has_any_active: Option<(bool,)> = sqlx::query_as(
|
||||||
"SELECT COUNT(*) > 0 FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE"
|
"SELECT COUNT(*) > 0 FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE"
|
||||||
).bind(provider_id).fetch_optional(db).await?;
|
).bind(provider_id).fetch_optional(db).await?;
|
||||||
|
|
||||||
if has_any_key.is_some_and(|(b,)| b) {
|
if has_any_active.is_some_and(|(b,)| b) {
|
||||||
// 有 key 但全部 cooldown 或超限 — 检查最快恢复时间
|
// 有活跃 key 但全部 cooldown 或超限 — 检查最快恢复时间
|
||||||
let cooldown_row: Option<(String,)> = sqlx::query_as(
|
let cooldown_row: Option<(String,)> = sqlx::query_as(
|
||||||
"SELECT cooldown_until::TEXT FROM provider_keys
|
"SELECT cooldown_until::TEXT FROM provider_keys
|
||||||
WHERE provider_id = $1 AND is_active = TRUE AND cooldown_until IS NOT NULL AND cooldown_until::timestamptz > $2
|
WHERE provider_id = $1 AND is_active = TRUE AND cooldown_until IS NOT NULL AND cooldown_until::timestamptz > $2
|
||||||
@@ -169,7 +169,79 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id)))
|
// 没有活跃 Key — 自动恢复 cooldown 已过期但 is_active=false 的 Key
|
||||||
|
let reactivated: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"UPDATE provider_keys SET is_active = TRUE, cooldown_until = NULL, updated_at = NOW()
|
||||||
|
WHERE provider_id = $1 AND is_active = FALSE
|
||||||
|
AND (cooldown_until IS NOT NULL AND cooldown_until::timestamptz <= $2)
|
||||||
|
RETURNING (SELECT COUNT(*) FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE)"
|
||||||
|
).bind(provider_id).bind(&now).fetch_optional(db).await?;
|
||||||
|
|
||||||
|
if let Some((active_count,)) = &reactivated {
|
||||||
|
if *active_count > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"Provider {} 自动恢复了 {} 个 cooldown 过期的 Key,重试选择",
|
||||||
|
provider_id, active_count
|
||||||
|
);
|
||||||
|
invalidate_cache(provider_id);
|
||||||
|
// 重试查询(不用递归,直接再走一次查询逻辑)
|
||||||
|
let retry_rows: Vec<(String, String, i32, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm,
|
||||||
|
COALESCE(SUM(uw.request_count), 0)::bigint,
|
||||||
|
COALESCE(SUM(uw.token_count), 0)::bigint
|
||||||
|
FROM provider_keys pk
|
||||||
|
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id
|
||||||
|
AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI')
|
||||||
|
WHERE pk.provider_id = $1 AND pk.is_active = TRUE
|
||||||
|
AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= $2)
|
||||||
|
GROUP BY pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm
|
||||||
|
ORDER BY pk.priority ASC, pk.last_used_at ASC NULLS FIRST"
|
||||||
|
).bind(provider_id).bind(&now).fetch_all(db).await?;
|
||||||
|
|
||||||
|
for (id, key_value, _priority, max_rpm, max_tpm, req_count, token_count) in &retry_rows {
|
||||||
|
if let Some(rpm_limit) = max_rpm {
|
||||||
|
if *rpm_limit > 0 && req_count.unwrap_or(0) >= *rpm_limit {
|
||||||
|
tracing::debug!("[retry] Reactivated key {} hit RPM limit ({}/{})", id, req_count.unwrap_or(0), rpm_limit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tpm_limit) = max_tpm {
|
||||||
|
if *tpm_limit > 0 && token_count.unwrap_or(0) >= *tpm_limit {
|
||||||
|
tracing::debug!("[retry] Reactivated key {} hit TPM limit ({}/{})", id, token_count.unwrap_or(0), tpm_limit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let decrypted_kv = match decrypt_key_value(key_value, enc_key) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[retry] Reactivated key {} decryption failed: {}", id, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let selection = KeySelection {
|
||||||
|
key: PoolKey { id: id.clone(), key_value: decrypted_kv, priority: *_priority, max_rpm: *max_rpm, max_tpm: *max_tpm },
|
||||||
|
key_id: id.clone(),
|
||||||
|
};
|
||||||
|
get_cache().insert(provider_id.to_string(), CachedSelection {
|
||||||
|
selection: selection.clone(),
|
||||||
|
cached_at: Instant::now(),
|
||||||
|
});
|
||||||
|
return Ok(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有恢复的 Key 仍被 RPM/TPM 限制或解密失败
|
||||||
|
tracing::warn!("Provider {} 恢复的 Key 全部不可用(RPM/TPM 超限或解密失败)", provider_id);
|
||||||
|
return Err(SaasError::RateLimited(
|
||||||
|
format!("Provider {} 恢复的 Key 仍在限流中,请稍后重试", provider_id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(SaasError::NotFound(format!(
|
||||||
|
"Provider {} 没有可用的 API Key(所有 Key 已停用,请在管理后台激活)",
|
||||||
|
provider_id
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录 Key 使用量(滑动窗口)
|
/// 记录 Key 使用量(滑动窗口)
|
||||||
@@ -229,14 +301,14 @@ pub async fn mark_key_429(
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE provider_keys SET last_429_at = $1, cooldown_until = $2, updated_at = $3
|
"UPDATE provider_keys SET last_429_at = $1, cooldown_until = $2, is_active = FALSE, updated_at = $3
|
||||||
WHERE id = $4"
|
WHERE id = $4"
|
||||||
)
|
)
|
||||||
.bind(&now).bind(&cooldown).bind(&now).bind(key_id)
|
.bind(&now).bind(&cooldown).bind(&now).bind(key_id)
|
||||||
.execute(db).await?;
|
.execute(db).await?;
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Key {} 收到 429,冷却至 {}",
|
"Key {} 收到 429,标记 is_active=FALSE,冷却至 {}",
|
||||||
key_id,
|
key_id,
|
||||||
cooldown
|
cooldown
|
||||||
);
|
);
|
||||||
@@ -315,9 +387,16 @@ pub async fn toggle_key_active(
|
|||||||
active: bool,
|
active: bool,
|
||||||
) -> SaasResult<()> {
|
) -> SaasResult<()> {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
sqlx::query(
|
// When activating, clear cooldown so the key is immediately selectable
|
||||||
"UPDATE provider_keys SET is_active = $1, updated_at = $2 WHERE id = $3"
|
if active {
|
||||||
).bind(active).bind(&now).bind(key_id).execute(db).await?;
|
sqlx::query(
|
||||||
|
"UPDATE provider_keys SET is_active = $1, cooldown_until = NULL, updated_at = $2 WHERE id = $3"
|
||||||
|
).bind(active).bind(&now).bind(key_id).execute(db).await?;
|
||||||
|
} else {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE provider_keys SET is_active = $1, updated_at = $2 WHERE id = $3"
|
||||||
|
).bind(active).bind(&now).bind(key_id).execute(db).await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
247
crates/zclaw-skills/tests/loader_tests.rs
Normal file
247
crates/zclaw-skills/tests/loader_tests.rs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
//! Tests for skill loader — SKILL.md and TOML parsing
|
||||||
|
|
||||||
|
use zclaw_skills::*;
|
||||||
|
|
||||||
|
// === parse_skill_md ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_basic_frontmatter() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Code Reviewer"
|
||||||
|
description: "Reviews code"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: prompt-only
|
||||||
|
tags: coding, review
|
||||||
|
---
|
||||||
|
# Code Reviewer
|
||||||
|
Reviews code for quality.
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.name, "Code Reviewer");
|
||||||
|
assert_eq!(manifest.description, "Reviews code");
|
||||||
|
assert_eq!(manifest.version, "1.0.0");
|
||||||
|
assert_eq!(manifest.mode, zclaw_skills::SkillMode::PromptOnly);
|
||||||
|
assert_eq!(manifest.tags, vec!["coding", "review"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_with_triggers_list() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Translator"
|
||||||
|
description: "Translates text"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: prompt-only
|
||||||
|
triggers:
|
||||||
|
- "翻译"
|
||||||
|
- "translate"
|
||||||
|
- "中译英"
|
||||||
|
---
|
||||||
|
# Translator
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.triggers, vec!["翻译", "translate", "中译英"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_with_tools_list() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Builder"
|
||||||
|
description: "Builds projects"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: shell
|
||||||
|
tools:
|
||||||
|
- "bash"
|
||||||
|
- "cargo"
|
||||||
|
---
|
||||||
|
# Builder
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.tools, vec!["bash", "cargo"]);
|
||||||
|
assert_eq!(manifest.mode, zclaw_skills::SkillMode::Shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_with_category() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Math Solver"
|
||||||
|
description: "Solves math problems"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: prompt-only
|
||||||
|
category: "math"
|
||||||
|
---
|
||||||
|
# Math Solver
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.category.unwrap(), "math");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_auto_classify_coding() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Code Helper"
|
||||||
|
description: "Helps with programming and debugging"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: prompt-only
|
||||||
|
---
|
||||||
|
# Code Helper
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
// Should auto-classify as "coding" based on description
|
||||||
|
assert_eq!(manifest.category.unwrap(), "coding");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_auto_classify_translation() {
|
||||||
|
let content = r#"---
|
||||||
|
name: "Translator"
|
||||||
|
description: "Helps with translation between languages"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: prompt-only
|
||||||
|
---
|
||||||
|
# Translator
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
// Should auto-classify based on "translat" keyword
|
||||||
|
assert!(manifest.category.is_some(), "Should auto-classify translation skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_no_frontmatter_extracts_name() {
|
||||||
|
let content = "# My Skill\n\nThis is a cool skill.";
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.name, "My Skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_fallback_name() {
|
||||||
|
let content = "Just some text without structure.";
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.name, "unnamed-skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_id_generation() {
|
||||||
|
let content = "---\nname: \"Hello World\"\n---\n";
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.id.as_str(), "hello-world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_all_modes() {
|
||||||
|
for (mode_str, expected) in &[
|
||||||
|
("prompt-only", zclaw_skills::SkillMode::PromptOnly),
|
||||||
|
("python", zclaw_skills::SkillMode::Python),
|
||||||
|
("shell", zclaw_skills::SkillMode::Shell),
|
||||||
|
("wasm", zclaw_skills::SkillMode::Wasm),
|
||||||
|
("native", zclaw_skills::SkillMode::Native),
|
||||||
|
] {
|
||||||
|
let content = format!("---\nname: \"Test\"\nmode: {}\n---\n", mode_str);
|
||||||
|
let manifest = parse_skill_md(&content).unwrap();
|
||||||
|
assert_eq!(&manifest.mode, expected, "Failed for mode: {}", mode_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_md_capabilities_csv() {
|
||||||
|
let content = "---\nname: \"Multi\"\ncapabilities: llm, web, file\n---\n";
|
||||||
|
let manifest = parse_skill_md(content).unwrap();
|
||||||
|
assert_eq!(manifest.capabilities, vec!["llm", "web", "file"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === parse_skill_toml ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_basic() {
|
||||||
|
let content = r#"
|
||||||
|
name = "Calculator"
|
||||||
|
description = "Performs calculations"
|
||||||
|
version = "2.0.0"
|
||||||
|
mode = "prompt_only"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.name, "Calculator");
|
||||||
|
assert_eq!(manifest.description, "Performs calculations");
|
||||||
|
assert_eq!(manifest.version, "2.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_with_id() {
|
||||||
|
let content = r#"
|
||||||
|
id = "my-calc"
|
||||||
|
name = "Calculator"
|
||||||
|
description = "Calc"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.id.as_str(), "my-calc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_generates_id_from_name() {
|
||||||
|
let content = "name = \"Hello World\"\ndescription = \"x\"";
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.id.as_str(), "hello-world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_requires_name() {
|
||||||
|
let content = r#"description = "no name""#;
|
||||||
|
let result = parse_skill_toml(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_arrays() {
|
||||||
|
let content = r#"
|
||||||
|
name = "X"
|
||||||
|
description = "x"
|
||||||
|
tags = ["a", "b", "c"]
|
||||||
|
capabilities = ["llm"]
|
||||||
|
triggers = ["go", "run"]
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.tags, vec!["a", "b", "c"]);
|
||||||
|
assert_eq!(manifest.capabilities, vec!["llm"]);
|
||||||
|
assert_eq!(manifest.triggers, vec!["go", "run"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_category() {
|
||||||
|
let content = r#"
|
||||||
|
name = "X"
|
||||||
|
description = "x"
|
||||||
|
category = "data"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.category.unwrap(), "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_tools() {
|
||||||
|
let content = r#"
|
||||||
|
name = "X"
|
||||||
|
description = "x"
|
||||||
|
tools = ["bash", "cargo"]
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.tools, vec!["bash", "cargo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skill_toml_ignores_comments_and_sections() {
|
||||||
|
let content = r#"
|
||||||
|
# This is a comment
|
||||||
|
[section]
|
||||||
|
name = "X"
|
||||||
|
description = "x"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_skill_toml(content).unwrap();
|
||||||
|
assert_eq!(manifest.name, "X");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === discover_skills ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_skills_nonexistent_dir() {
|
||||||
|
let result = discover_skills(std::path::Path::new("/nonexistent/path")).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
78
crates/zclaw-skills/tests/runner_tests.rs
Normal file
78
crates/zclaw-skills/tests/runner_tests.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
//! Tests for PromptOnlySkill runner
|
||||||
|
|
||||||
|
use zclaw_skills::*;
|
||||||
|
use zclaw_types::SkillId;
|
||||||
|
|
||||||
|
/// Helper to create a minimal manifest
|
||||||
|
fn test_manifest(mode: SkillMode) -> SkillManifest {
|
||||||
|
SkillManifest {
|
||||||
|
id: SkillId::new("test-prompt-skill"),
|
||||||
|
name: "Test Prompt Skill".to_string(),
|
||||||
|
description: "A test prompt skill".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
author: None,
|
||||||
|
mode,
|
||||||
|
capabilities: vec![],
|
||||||
|
input_schema: None,
|
||||||
|
output_schema: None,
|
||||||
|
tags: vec![],
|
||||||
|
category: None,
|
||||||
|
triggers: vec![],
|
||||||
|
tools: vec![],
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_only_skill_returns_formatted_prompt() {
|
||||||
|
let manifest = test_manifest(SkillMode::PromptOnly);
|
||||||
|
let template = "Hello {{input}}, welcome!".to_string();
|
||||||
|
let skill = PromptOnlySkill::new(manifest, template);
|
||||||
|
|
||||||
|
let ctx = SkillContext::default();
|
||||||
|
let skill_ref: &dyn Skill = &skill;
|
||||||
|
let result = skill_ref.execute(&ctx, serde_json::json!("World")).await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
let output = result.output.as_str().unwrap();
|
||||||
|
assert_eq!(output, "Hello World, welcome!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_only_skill_json_input() {
|
||||||
|
let manifest = test_manifest(SkillMode::PromptOnly);
|
||||||
|
let template = "Input: {{input}}".to_string();
|
||||||
|
let skill = PromptOnlySkill::new(manifest, template);
|
||||||
|
|
||||||
|
let ctx = SkillContext::default();
|
||||||
|
let input = serde_json::json!({"key": "value"});
|
||||||
|
let skill_ref: &dyn Skill = &skill;
|
||||||
|
let result = skill_ref.execute(&ctx, input).await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
let output = result.output.as_str().unwrap();
|
||||||
|
assert!(output.contains("key"));
|
||||||
|
assert!(output.contains("value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_only_skill_no_placeholder() {
|
||||||
|
let manifest = test_manifest(SkillMode::PromptOnly);
|
||||||
|
let template = "Static prompt content".to_string();
|
||||||
|
let skill = PromptOnlySkill::new(manifest, template);
|
||||||
|
|
||||||
|
let ctx = SkillContext::default();
|
||||||
|
let skill_ref: &dyn Skill = &skill;
|
||||||
|
let result = skill_ref.execute(&ctx, serde_json::json!("ignored")).await.unwrap();
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(result.output.as_str().unwrap(), "Static prompt content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prompt_only_skill_manifest() {
|
||||||
|
let manifest = test_manifest(SkillMode::PromptOnly);
|
||||||
|
let skill = PromptOnlySkill::new(manifest.clone(), "prompt".to_string());
|
||||||
|
assert_eq!(skill.manifest().id.as_str(), "test-prompt-skill");
|
||||||
|
assert_eq!(skill.manifest().name, "Test Prompt Skill");
|
||||||
|
}
|
||||||
148
crates/zclaw-skills/tests/skill_types_tests.rs
Normal file
148
crates/zclaw-skills/tests/skill_types_tests.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! Tests for zclaw-skills types: SkillManifest, SkillMode, SkillResult, SkillContext
|
||||||
|
|
||||||
|
use serde_json;
|
||||||
|
use zclaw_skills::*;
|
||||||
|
use zclaw_types::SkillId;
|
||||||
|
|
||||||
|
// === SkillMode ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_mode_serialization_roundtrip() {
|
||||||
|
let modes = vec![
|
||||||
|
SkillMode::PromptOnly,
|
||||||
|
SkillMode::Python,
|
||||||
|
SkillMode::Shell,
|
||||||
|
SkillMode::Wasm,
|
||||||
|
SkillMode::Native,
|
||||||
|
];
|
||||||
|
for mode in modes {
|
||||||
|
let json = serde_json::to_string(&mode).unwrap();
|
||||||
|
let parsed: SkillMode = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(mode, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_mode_snake_case_serialization() {
|
||||||
|
let json = serde_json::to_string(&SkillMode::PromptOnly).unwrap();
|
||||||
|
assert!(json.contains("prompt_only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SkillResult ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_result_success() {
|
||||||
|
let result = SkillResult::success(serde_json::json!({"answer": 42}));
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.error.is_none());
|
||||||
|
assert_eq!(result.output["answer"], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_result_error() {
|
||||||
|
let result = SkillResult::error("execution failed");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert_eq!(result.error.unwrap(), "execution failed");
|
||||||
|
assert!(result.output.is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_result_roundtrip() {
|
||||||
|
let result = SkillResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::json!("hello"),
|
||||||
|
error: None,
|
||||||
|
duration_ms: Some(150),
|
||||||
|
tokens_used: Some(42),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
let parsed: SkillResult = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.success);
|
||||||
|
assert_eq!(parsed.duration_ms.unwrap(), 150);
|
||||||
|
assert_eq!(parsed.tokens_used.unwrap(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SkillManifest ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_manifest_full_roundtrip() {
|
||||||
|
let manifest = SkillManifest {
|
||||||
|
id: SkillId::new("test-skill"),
|
||||||
|
name: "Test Skill".to_string(),
|
||||||
|
description: "A test skill".to_string(),
|
||||||
|
version: "2.0.0".to_string(),
|
||||||
|
author: Some("tester".to_string()),
|
||||||
|
mode: SkillMode::PromptOnly,
|
||||||
|
capabilities: vec!["llm".to_string()],
|
||||||
|
input_schema: Some(serde_json::json!({"type": "object"})),
|
||||||
|
output_schema: None,
|
||||||
|
tags: vec!["test".to_string()],
|
||||||
|
category: Some("coding".to_string()),
|
||||||
|
triggers: vec!["test trigger".to_string()],
|
||||||
|
tools: vec!["bash".to_string()],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&manifest).unwrap();
|
||||||
|
let parsed: SkillManifest = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.id.as_str(), "test-skill");
|
||||||
|
assert_eq!(parsed.name, "Test Skill");
|
||||||
|
assert_eq!(parsed.mode, SkillMode::PromptOnly);
|
||||||
|
assert_eq!(parsed.capabilities.len(), 1);
|
||||||
|
assert_eq!(parsed.triggers.len(), 1);
|
||||||
|
assert_eq!(parsed.tools.len(), 1);
|
||||||
|
assert_eq!(parsed.category.unwrap(), "coding");
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_manifest_default_enabled() {
|
||||||
|
let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only"}"#;
|
||||||
|
let manifest: SkillManifest = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(manifest.enabled, "enabled should default to true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_manifest_disabled() {
|
||||||
|
let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only","enabled":false}"#;
|
||||||
|
let manifest: SkillManifest = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!manifest.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_manifest_all_modes_roundtrip() {
|
||||||
|
for mode in &[SkillMode::PromptOnly, SkillMode::Python, SkillMode::Shell, SkillMode::Wasm] {
|
||||||
|
let manifest = SkillManifest {
|
||||||
|
id: SkillId::new("m"),
|
||||||
|
name: "M".into(),
|
||||||
|
description: "d".into(),
|
||||||
|
version: "1.0".into(),
|
||||||
|
author: None,
|
||||||
|
mode: mode.clone(),
|
||||||
|
capabilities: vec![],
|
||||||
|
input_schema: None,
|
||||||
|
output_schema: None,
|
||||||
|
tags: vec![],
|
||||||
|
category: None,
|
||||||
|
triggers: vec![],
|
||||||
|
tools: vec![],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&manifest).unwrap();
|
||||||
|
let parsed: SkillManifest = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(*mode, parsed.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SkillContext ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_context_default() {
|
||||||
|
let ctx = SkillContext::default();
|
||||||
|
assert!(ctx.agent_id.is_empty());
|
||||||
|
assert!(ctx.session_id.is_empty());
|
||||||
|
assert!(ctx.working_dir.is_none());
|
||||||
|
assert_eq!(ctx.timeout_secs, 60);
|
||||||
|
assert!(!ctx.network_allowed);
|
||||||
|
assert!(!ctx.file_access_allowed);
|
||||||
|
assert!(ctx.llm.is_none());
|
||||||
|
}
|
||||||
@@ -47,9 +47,30 @@ pub async fn health_snapshot(
|
|||||||
) -> Result<HealthSnapshot, String> {
|
) -> Result<HealthSnapshot, String> {
|
||||||
let engines = heartbeat_state.lock().await;
|
let engines = heartbeat_state.lock().await;
|
||||||
|
|
||||||
let engine = engines
|
// If heartbeat engine not yet initialized, return a graceful "pending" snapshot
|
||||||
.get(&agent_id)
|
// instead of erroring — this avoids race conditions when HealthPanel mounts
|
||||||
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
|
// before the heartbeat bootstrap sequence completes.
|
||||||
|
let engine = match engines.get(&agent_id) {
|
||||||
|
Some(e) => e,
|
||||||
|
None => {
|
||||||
|
tracing::debug!("[health_snapshot] Engine not initialized for {}, returning pending snapshot", agent_id);
|
||||||
|
return Ok(HealthSnapshot {
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
intelligence: IntelligenceHealth {
|
||||||
|
engine_running: false,
|
||||||
|
config: HeartbeatConfig::default(),
|
||||||
|
last_tick: None,
|
||||||
|
alert_count_24h: 0,
|
||||||
|
total_checks: 5,
|
||||||
|
},
|
||||||
|
memory: MemoryHealth {
|
||||||
|
total_entries: 0,
|
||||||
|
storage_size_bytes: 0,
|
||||||
|
last_extraction: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let engine_running = engine.is_running().await;
|
let engine_running = engine.is_running().await;
|
||||||
let config = engine.get_config().await;
|
let config = engine.get_config().await;
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ export function OfflineIndicator({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tauri desktop: suppress "已恢复连接" state — only show real offline
|
||||||
|
const isTauri = !!(window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__;
|
||||||
|
if (isTauri && !isOffline) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Compact version for headers/toolbars
|
// Compact version for headers/toolbars
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,29 @@ import { createLogger } from '../lib/logger';
|
|||||||
|
|
||||||
const log = createLogger('AgentStore');
|
const log = createLogger('AgentStore');
|
||||||
|
|
||||||
|
// === Error Classification ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract HTTP status code from typed errors or Tauri invoke errors.
|
||||||
|
* Falls back to substring matching only for untyped error strings.
|
||||||
|
*/
|
||||||
|
function classifyAgentError(err: unknown, prefix = '操作失败'): string {
|
||||||
|
// Typed error paths — no false positives
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const status = (err as { status?: number }).status;
|
||||||
|
if (typeof status === 'number') {
|
||||||
|
if (status === 502) return `${prefix}:后端服务暂时不可用,请稍后重试。如果问题持续,请检查 Provider Key 是否已激活。`;
|
||||||
|
if (status === 503) return `${prefix}:服务暂不可用,请稍后重试。`;
|
||||||
|
if (status === 401) return `${prefix}:登录已过期,请重新登录后重试。`;
|
||||||
|
if (status === 403) return `${prefix}:权限不足,请检查账户权限。`;
|
||||||
|
if (status === 429) return `${prefix}:请求过于频繁,请稍后重试。`;
|
||||||
|
if (status === 500) return `${prefix}:服务器内部错误,请稍后重试。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: generic message, no internal details leaked
|
||||||
|
return `${prefix}:发生未知错误,请稍后重试。`;
|
||||||
|
}
|
||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
|
||||||
export interface Clone {
|
export interface Clone {
|
||||||
@@ -188,8 +211,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
await get().loadClones(); // Refresh the list
|
await get().loadClones(); // Refresh the list
|
||||||
return result?.clone;
|
return result?.clone;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
log.error('[AgentStore] createClone error:', err);
|
||||||
set({ error: errorMessage, isLoading: false });
|
const userMsg = classifyAgentError(err, '创建失败');
|
||||||
|
set({ error: userMsg, isLoading: false });
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -318,7 +342,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ error: String(error) });
|
log.error('[AgentStore] createFromTemplate error:', error);
|
||||||
|
const userMsg = classifyAgentError(error, '创建失败');
|
||||||
|
set({ error: userMsg });
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
@@ -338,8 +364,8 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
await get().loadClones(); // Refresh the list
|
await get().loadClones(); // Refresh the list
|
||||||
return result?.clone;
|
return result?.clone;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
log.error('[AgentStore] updateClone error:', err);
|
||||||
set({ error: errorMessage, isLoading: false });
|
set({ error: classifyAgentError(err, '更新失败'), isLoading: false });
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -356,8 +382,8 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
await client.deleteClone(id);
|
await client.deleteClone(id);
|
||||||
await get().loadClones(); // Refresh the list
|
await get().loadClones(); // Refresh the list
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
log.error('[AgentStore] deleteClone error:', err);
|
||||||
set({ error: errorMessage, isLoading: false });
|
set({ error: classifyAgentError(err, '删除失败'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export interface ConfigActionsSlice {
|
|||||||
description?: string;
|
description?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) => Promise<ScheduledTask | undefined>;
|
}) => Promise<ScheduledTask | undefined>;
|
||||||
loadSkillsCatalog: () => Promise<void>;
|
loadSkillsCatalog: (retryCount?: number) => Promise<void>;
|
||||||
getSkill: (id: string) => Promise<SkillInfo | undefined>;
|
getSkill: (id: string) => Promise<SkillInfo | undefined>;
|
||||||
createSkill: (skill: {
|
createSkill: (skill: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -449,7 +449,7 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
|
|||||||
|
|
||||||
// === Skill Actions ===
|
// === Skill Actions ===
|
||||||
|
|
||||||
loadSkillsCatalog: async () => {
|
loadSkillsCatalog: async (retryCount = 0) => {
|
||||||
const client = get().client;
|
const client = get().client;
|
||||||
|
|
||||||
// Path A: via injected client (KernelClient or GatewayClient)
|
// Path A: via injected client (KernelClient or GatewayClient)
|
||||||
@@ -494,10 +494,19 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
|
|||||||
source: ((s.source as string) || 'builtin') as 'builtin' | 'extra',
|
source: ((s.source as string) || 'builtin') as 'builtin' | 'extra',
|
||||||
path: s.path as string | undefined,
|
path: s.path as string | undefined,
|
||||||
})) });
|
})) });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[configStore] skill_list direct invoke also failed:', err);
|
console.warn('[configStore] skill_list direct invoke also failed:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path C: delayed retry — kernel may still be initializing
|
||||||
|
if (retryCount < 2) {
|
||||||
|
const delay = (retryCount + 1) * 1500; // 1.5s, 3s
|
||||||
|
console.log(`[configStore] Skills empty, retrying in ${delay}ms (attempt ${retryCount + 1}/2)`);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
return get().loadSkillsCatalog(retryCount + 1);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getSkill: async (id: string) => {
|
getSkill: async (id: string) => {
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ const log = createLogger('SaaSStore:Auth');
|
|||||||
type SetFn = (partial: Partial<SaaSStore> | ((state: SaaSStore) => Partial<SaaSStore>)) => void;
|
type SetFn = (partial: Partial<SaaSStore> | ((state: SaaSStore) => Partial<SaaSStore>)) => void;
|
||||||
type GetFn = () => SaaSStore;
|
type GetFn = () => SaaSStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger reconnection after authentication changes (login, TOTP, restore).
|
||||||
|
* Only reconnects when actually disconnected to avoid double-connect race.
|
||||||
|
*/
|
||||||
|
async function triggerReconnect(context: string) {
|
||||||
|
try {
|
||||||
|
const { useConnectionStore } = await import('../connectionStore');
|
||||||
|
const connState = useConnectionStore.getState();
|
||||||
|
if (connState.connectionState === 'disconnected') {
|
||||||
|
log.info(`[${context}] Reconnecting after auth change`);
|
||||||
|
connState.connect().catch((err: unknown) => log.warn(`[${context}] Reconnect failed:`, err));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`[${context}] Failed to trigger reconnect:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuthSlice(set: SetFn, get: GetFn) {
|
export function createAuthSlice(set: SetFn, get: GetFn) {
|
||||||
// Restore session metadata synchronously (URL + account only).
|
// Restore session metadata synchronously (URL + account only).
|
||||||
const sessionMeta = loadSaaSSessionSync();
|
const sessionMeta = loadSaaSSessionSync();
|
||||||
@@ -87,6 +104,8 @@ export function createAuthSlice(set: SetFn, get: GetFn) {
|
|||||||
get().pushConfigToSaaS().catch((err: unknown) => log.warn('Failed to push config to SaaS:', err));
|
get().pushConfigToSaaS().catch((err: unknown) => log.warn('Failed to push config to SaaS:', err));
|
||||||
}).catch((err: unknown) => log.warn('Failed to sync config after login:', err));
|
}).catch((err: unknown) => log.warn('Failed to sync config after login:', err));
|
||||||
|
|
||||||
|
triggerReconnect('SaaS Auth');
|
||||||
|
|
||||||
initTelemetryCollector(DEVICE_ID);
|
initTelemetryCollector(DEVICE_ID);
|
||||||
startPromptOTASync(DEVICE_ID);
|
startPromptOTASync(DEVICE_ID);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -144,6 +163,7 @@ export function createAuthSlice(set: SetFn, get: GetFn) {
|
|||||||
|
|
||||||
get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device:', err));
|
get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device:', err));
|
||||||
get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models:', err));
|
get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models:', err));
|
||||||
|
triggerReconnect('SaaS Auth TOTP');
|
||||||
initTelemetryCollector(DEVICE_ID);
|
initTelemetryCollector(DEVICE_ID);
|
||||||
startPromptOTASync(DEVICE_ID);
|
startPromptOTASync(DEVICE_ID);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -301,6 +321,7 @@ export function createAuthSlice(set: SetFn, get: GetFn) {
|
|||||||
get().syncConfigFromSaaS().then(() => {
|
get().syncConfigFromSaaS().then(() => {
|
||||||
get().pushConfigToSaaS().catch(() => {});
|
get().pushConfigToSaaS().catch(() => {});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
triggerReconnect('SaaS Restore');
|
||||||
initTelemetryCollector(DEVICE_ID);
|
initTelemetryCollector(DEVICE_ID);
|
||||||
startPromptOTASync(DEVICE_ID);
|
startPromptOTASync(DEVICE_ID);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ZCLAW 系统真相文档
|
# ZCLAW 系统真相文档
|
||||||
|
|
||||||
> **更新日期**: 2026-04-18
|
> **更新日期**: 2026-04-19
|
||||||
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13 + 发布前冲刺 Day1 2026-04-15 + 发布前深度测试 8 路并行代码级验证 2026-04-16 + 发布前审计 2026-04-18
|
> **数据来源**: V11 全面审计 + 二次审计 + V12 模块化端到端审计 + 代码全量扫描验证 + 功能测试 Phase 1-5 + 发布前功能测试 Phase 3 + 发布前全面测试代码级审计 + 2026-04-11 代码验证 + V13 系统性功能审计 2026-04-12 + V13 审计修复 2026-04-13 + 发布前冲刺 Day1 2026-04-15 + 发布前深度测试 8 路并行代码级验证 2026-04-16 + 发布前审计 2026-04-18 + sqlx 0.8 升级 + 测试覆盖补充 2026-04-19
|
||||||
> **规则**: 此文档是唯一真相源。所有其他文档如果与此冲突,以此为准。
|
> **规则**: 此文档是唯一真相源。所有其他文档如果与此冲突,以此为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
| Rust Crates | 10 个 (编译通过) | `cargo check --workspace` |
|
| Rust Crates | 10 个 (编译通过) | `cargo check --workspace` |
|
||||||
| Rust 代码行数 | ~77,000 (crates) + ~61,400 (src-tauri) = ~138,400 | wc -l (2026-04-12 V13 验证) |
|
| Rust 代码行数 | ~77,000 (crates) + ~61,400 (src-tauri) = ~138,400 | wc -l (2026-04-12 V13 验证) |
|
||||||
| Rust 单元测试 | 477 个 (#[test]) + 326 个 (#[tokio::test]) = 803 | `grep '#\[test\]' crates/` + `grep '#\[tokio::test\]'` (2026-04-18 审计验证) |
|
| Rust 单元测试 | 477 个 (#[test]) + 326 个 (#[tokio::test]) = 803 | `grep '#\[test\]' crates/` + `grep '#\[tokio::test\]'` (2026-04-18 审计验证) |
|
||||||
|
| Rust 测试运行通过 | 797 workspace (sqlx 0.8 升级后 2026-04-19 验证) | `cargo test --workspace --exclude zclaw-saas` |
|
||||||
| Cargo Warnings (非 SaaS) | **0 个** (仅 sqlx-postgres 外部依赖 1 个) | `cargo check --workspace --exclude zclaw-saas` (2026-04-15 清零) |
|
| Cargo Warnings (非 SaaS) | **0 个** (仅 sqlx-postgres 外部依赖 1 个) | `cargo check --workspace --exclude zclaw-saas` (2026-04-15 清零) |
|
||||||
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
| Rust 测试运行通过 | 684 workspace + 138 SaaS = 822 | Hermes 4 Chunk `cargo test --workspace` 2026-04-09 |
|
||||||
| Tauri 命令 | 190 个 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
| Tauri 命令 | 190 个 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
||||||
|
|||||||
25
wiki/log.md
25
wiki/log.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 变更日志
|
title: 变更日志
|
||||||
updated: 2026-04-17
|
updated: 2026-04-19
|
||||||
status: active
|
status: active
|
||||||
tags: [log, history]
|
tags: [log, history]
|
||||||
---
|
---
|
||||||
@@ -9,6 +9,29 @@ tags: [log, history]
|
|||||||
|
|
||||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
||||||
|
|
||||||
|
## 2026-04-19 fix | 穷尽审计修复 — CRITICAL×1 + HIGH×6 + MEDIUM×4
|
||||||
|
|
||||||
|
- C1: mark_key_429 设 is_active=FALSE,自动恢复路径可达化
|
||||||
|
- H1+H2: 重试查询补全日志 + fallthrough 错误信息修正 (RateLimited)
|
||||||
|
- H3+H4+M3+M4+M5: agentStore 提取 classifyAgentError() 类型化错误 + 全 CRUD 统一
|
||||||
|
- H5+H6: auth.ts 提取 triggerReconnect(),login/TOTP/restore 三路径统一
|
||||||
|
- M1: toggle_key_active(true) 清除 cooldown_until
|
||||||
|
|
||||||
|
## 2026-04-19 fix | 发布前审计 5 项修复
|
||||||
|
|
||||||
|
- P0-1: key_pool.rs Provider Key cooldown 过期自动恢复(is_active=false → true)
|
||||||
|
- P0-2: agentStore.ts createClone/createFromTemplate 友好错误信息(502/503/401 分类)
|
||||||
|
- P1-2: auth.ts login 成功后触发 connectionStore.connect() 重新配置 kernel token
|
||||||
|
- P1-3: health_snapshot heartbeat engine 未初始化时返回 pending 快照(不再报错)
|
||||||
|
- P1-1: configStore.ts loadSkillsCatalog 增加延迟重试(最多2次,1.5s/3s 间隔)
|
||||||
|
|
||||||
|
## 2026-04-19 chore | sqlx 0.7→0.8 统一 + 测试覆盖补充
|
||||||
|
|
||||||
|
- sqlx workspace 0.7→0.8.6 + libsqlite3-sys 0.27→0.30,消除 pgvector 引入的双版本
|
||||||
|
- 零源码修改,719→797 测试全通过
|
||||||
|
- zclaw-protocols +43 测试: MCP types serde / transport config / domain roundtrips
|
||||||
|
- zclaw-skills +47 测试: SKILL.md/TOML parsing / auto-classify / PromptOnlySkill / types roundtrips
|
||||||
|
|
||||||
## 2026-04-18 fix | 审计后续 3 项修复
|
## 2026-04-18 fix | 审计后续 3 项修复
|
||||||
|
|
||||||
- Shell Hands 残留清理 3 处 (message.rs 注释/profiler 偏好/handStore mock)
|
- Shell Hands 残留清理 3 处 (message.rs 注释/profiler 偏好/handStore mock)
|
||||||
|
|||||||
Reference in New Issue
Block a user