mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
* refactor: remove global_engine usage in tests and instantiate AccessibilityEngine directly - Updated the test suite to eliminate the use of global_engine, replacing it with a direct instantiation of AccessibilityEngine. - This change enhances test isolation and clarity by ensuring that each test operates with its own instance of the engine, improving reliability and maintainability. * feat: add socket module for skill communication - Introduced a new `socket` module to facilitate communication between skills, enhancing the modularity and organization of the codebase. - Updated imports in `qjs_engine.rs` to reference the new `SocketManager` from the `socket` module, streamlining socket management for skill interactions. - Removed the deprecated `socket_manager` module from the skills module, improving clarity and reducing redundancy in the code structure. * feat: integrate socket controllers and schemas into core functionality - Added socket-related registered controllers and schemas to the core build functions, enhancing the communication capabilities within the OpenHuman framework. - Updated the `build_registered_controllers` and `build_declared_controller_schemas` functions to include socket components, ensuring comprehensive integration of the new socket module. * feat: enhance QuickJS skill runtime with socket manager integration - Updated the `bootstrap_skill_runtime` function to initialize and register the `SocketManager` globally, allowing RPC handlers to access socket functionalities. - Improved documentation to reflect the addition of socket management capabilities alongside the QuickJS skill runtime. - Cleaned up imports in `event_handlers.rs` to streamline the codebase. * refactor: remove socket manager integration from skill runtime - Removed the `SocketManager` integration from the `bootstrap_skill_runtime` function, simplifying the socket management process. - Eliminated the `socket_manager` field and related methods from the `RuntimeEngine` struct, streamlining the codebase. - Cleaned up unused MCP handlers and socket-related imports in the event handlers, enhancing code clarity and maintainability. * refactor: remove sync_tools calls from skill status handling - Eliminated unnecessary calls to `sync_tools()` in the `RuntimeEngine` during skill status changes, simplifying the skill lifecycle management. - This change enhances performance by reducing redundant synchronization operations during skill execution and shutdown processes. * feat: enhance socket event handling with improved logging - Added logging for incoming socket events to improve observability, including event name and data size. - Implemented detailed debug logging for event payloads, ensuring clarity on the data being processed. - Updated event handling logic to streamline routing for webhook requests and inbound channel messages, enhancing the overall responsiveness of the system. - Introduced logging for unhandled events to aid in debugging and monitoring. * feat: enhance socket management and auto-connect functionality - Updated the `bootstrap_skill_runtime` function to clone the `SocketManager` instance for global registration, ensuring proper socket management. - Introduced background tasks for auto-starting skills and auto-connecting to the backend using stored session tokens, improving startup efficiency and user experience. - Added detailed logging for session token checks and connection attempts, enhancing observability and debugging capabilities during socket operations. * feat: enhance skill selection and tool management in tests - Introduced a new `manifests_in_dir` function to retrieve skill manifests from a specified directory, improving skill discovery. - Added `select_skill_id` function to prioritize skill selection based on environment variables and preferred candidates, enhancing flexibility in test configurations. - Updated the test suite to utilize the new skill selection logic, ensuring more robust and configurable test scenarios. - Improved handling of tool selection with enhanced logging and fallback mechanisms for better debugging and usability. * format code:wq * feat: add Rust checks and formatting commands to package.json - Introduced new scripts for Rust checks and formatting in both the main and app package.json files. - Updated pre-push hook to include Rust compile checks, enhancing pre-push validation. - Modified existing format commands to integrate Rust formatting, ensuring consistency across codebases. * fix: improve formatting of the "Keep Screenshots" label description - Adjusted the formatting of the description text for better readability by breaking it into multiple lines within the `ScreenIntelligencePanel` component. * fix: install rustls crypto provider before WebSocket TLS connect The socket auto-connect was panicking with "Could not automatically determine the process-level CryptoProvider" because tokio-tungstenite uses rustls for wss:// but no crypto provider was installed. Install the ring provider before each connect attempt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use dynamically selected skill in sync memory tests The tests hardcoded 'example-skill' which no longer exists in the skills directory. Now dynamically picks the first available skill (preferring server-ping) so tests work regardless of which skills are present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
419 lines
15 KiB
Rust
419 lines
15 KiB
Rust
//! Integration test: skill sync → memory persistence.
|
|
//!
|
|
//! Verifies that calling `skill/sync` via RPC:
|
|
//! 1. Invokes the skill's `onSync()` JS handler
|
|
//! 2. Persists published state to the local memory store
|
|
//!
|
|
//! Also tests that cron-triggered syncs and tick persist to memory.
|
|
//!
|
|
//! Run:
|
|
//! cargo test --test skills_sync_memory_test -- --nocapture
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::json;
|
|
use tempfile::tempdir;
|
|
|
|
use openhuman_core::core::all::try_invoke_registered_rpc;
|
|
use openhuman_core::openhuman::memory::MemoryClient;
|
|
use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
|
|
|
|
/// Serializes tests in this binary: `set_global_engine` mutates the process-wide
|
|
/// GLOBAL_ENGINE, so parallel tests would cross-wire engine instances.
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.expect("skills_sync_memory_test env lock poisoned")
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
fn try_find_skills_dir() -> Option<PathBuf> {
|
|
if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") {
|
|
let p = PathBuf::from(&dir);
|
|
return if p.exists() { Some(p) } else { None };
|
|
}
|
|
if let Ok(dir) = std::env::var("SKILLS_LOCAL_DIR") {
|
|
let p = PathBuf::from(&dir);
|
|
if p.exists() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
let cwd = std::env::current_dir().expect("cwd");
|
|
for candidate in &[
|
|
"../openhuman-skills/skills",
|
|
"openhuman-skills/skills",
|
|
"../alphahuman/skills/skills",
|
|
] {
|
|
let p = cwd.join(candidate);
|
|
if p.exists() {
|
|
return Some(p.canonicalize().unwrap());
|
|
}
|
|
}
|
|
if let Some(parent) = cwd.parent() {
|
|
for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() {
|
|
let c = entry.path().join("skills/skills");
|
|
if c.join("example-skill/manifest.json").exists() {
|
|
return Some(c.canonicalize().unwrap());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
macro_rules! require_skills_dir {
|
|
() => {
|
|
match try_find_skills_dir() {
|
|
Some(dir) => dir,
|
|
None => {
|
|
eprintln!("SKIPPED: no skills directory available (set SKILL_DEBUG_DIR for CI)");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Pick a skill that actually exists in the skills directory.
|
|
fn select_skill_id(skills_dir: &Path) -> String {
|
|
if let Ok(id) = std::env::var("SKILL_DEBUG_ID") {
|
|
if !id.trim().is_empty() {
|
|
return id;
|
|
}
|
|
}
|
|
// Preferred order: simple skills first.
|
|
for candidate in &["server-ping", "example-skill", "gmail", "notion"] {
|
|
if skills_dir.join(candidate).join("manifest.json").exists() {
|
|
return (*candidate).to_string();
|
|
}
|
|
}
|
|
// Fallback: first directory with a manifest.json.
|
|
if let Ok(entries) = std::fs::read_dir(skills_dir) {
|
|
for entry in entries.flatten() {
|
|
let p = entry.path();
|
|
if p.is_dir() && p.join("manifest.json").exists() {
|
|
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
|
|
return name.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"example-skill".to_string()
|
|
}
|
|
|
|
async fn create_engine_with_memory(
|
|
skills_dir: &Path,
|
|
data_dir: &Path,
|
|
workspace_dir: &Path,
|
|
) -> (Arc<RuntimeEngine>, Arc<MemoryClient>) {
|
|
let engine =
|
|
RuntimeEngine::new(data_dir.to_path_buf()).expect("RuntimeEngine::new should succeed");
|
|
let engine = Arc::new(engine);
|
|
engine.set_skills_source_dir(skills_dir.to_path_buf());
|
|
|
|
// Create a MemoryClient pointing at the temp workspace
|
|
let memory_client =
|
|
MemoryClient::from_workspace_dir(workspace_dir.to_path_buf()).expect("MemoryClient");
|
|
let memory_client = Arc::new(memory_client);
|
|
|
|
// Wire the memory client into the engine so event_loop can use it
|
|
engine.set_memory_client(memory_client.clone());
|
|
|
|
// Set as global so RPC handlers can find it
|
|
set_global_engine(engine.clone());
|
|
|
|
(engine, memory_client)
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
/// Test that `skill/sync` RPC triggers `onSync()` and persists state to memory.
|
|
#[tokio::test]
|
|
async fn sync_rpc_persists_to_memory() {
|
|
let _lock = env_lock();
|
|
let _ = env_logger::builder()
|
|
.filter_level(log::LevelFilter::Debug)
|
|
.is_test(true)
|
|
.try_init();
|
|
|
|
let skills_dir = require_skills_dir!();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let data_dir = tmp.path().join("skills_data");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(&data_dir).unwrap();
|
|
std::fs::create_dir_all(&workspace_dir).unwrap();
|
|
|
|
let skill_id = &select_skill_id(&skills_dir);
|
|
|
|
eprintln!("\n=== sync_rpc_persists_to_memory ===");
|
|
eprintln!(" Skill ID: {skill_id}");
|
|
eprintln!(" Skills dir: {}", skills_dir.display());
|
|
eprintln!(" Data dir: {}", data_dir.display());
|
|
eprintln!(" Workspace dir: {}", workspace_dir.display());
|
|
|
|
let (engine, memory_client) =
|
|
create_engine_with_memory(&skills_dir, &data_dir, &workspace_dir).await;
|
|
|
|
// ── Start skill ──
|
|
eprintln!("\n--- Start skill '{skill_id}' ---");
|
|
let snap = engine.start_skill(skill_id).await.expect("start");
|
|
eprintln!(" Status: {:?}, tools: {}", snap.status, snap.tools.len());
|
|
eprintln!(
|
|
" Published state keys: {:?}",
|
|
snap.state.keys().collect::<Vec<_>>()
|
|
);
|
|
|
|
// ── Verify no memory documents exist yet ──
|
|
eprintln!("\n--- Check memory before sync ---");
|
|
let namespace = format!("skill-{}", skill_id);
|
|
let before = memory_client
|
|
.list_documents(Some(&namespace))
|
|
.await
|
|
.expect("list_documents");
|
|
let before_count = before
|
|
.get("documents")
|
|
.and_then(|d| d.as_array())
|
|
.map(|a| a.len())
|
|
.unwrap_or(0);
|
|
eprintln!(" Documents in '{namespace}' before sync: {before_count}");
|
|
|
|
// ── Call skill/sync via RPC ──
|
|
eprintln!("\n--- Call skill/sync RPC ---");
|
|
let sync_result = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
engine.rpc(skill_id, "skill/sync", json!({})),
|
|
)
|
|
.await;
|
|
|
|
match &sync_result {
|
|
Ok(Ok(val)) => eprintln!(" skill/sync returned: {val}"),
|
|
Ok(Err(e)) => eprintln!(" skill/sync error: {e} (may be expected for example-skill)"),
|
|
Err(_) => panic!("skill/sync TIMED OUT"),
|
|
}
|
|
|
|
// ── Wait for fire-and-forget memory persistence ──
|
|
eprintln!("\n--- Waiting for memory persistence (2s) ---");
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
|
|
// ── Verify documents were created ──
|
|
eprintln!("\n--- Check memory after sync ---");
|
|
let after = memory_client
|
|
.list_documents(Some(&namespace))
|
|
.await
|
|
.expect("list_documents");
|
|
let after_docs = after
|
|
.get("documents")
|
|
.and_then(|d| d.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
eprintln!(
|
|
" Documents in '{namespace}' after sync: {}",
|
|
after_docs.len()
|
|
);
|
|
|
|
for doc in &after_docs {
|
|
let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("?");
|
|
let doc_id = doc
|
|
.get("documentId")
|
|
.and_then(|d| d.as_str())
|
|
.unwrap_or("?");
|
|
eprintln!(" - title: {title}, doc_id: {doc_id}");
|
|
}
|
|
|
|
// The example-skill may or may not publish state during onSync.
|
|
// If it does, we should see at least one document.
|
|
// If it doesn't (no state.set calls in onSync), the snapshot will be empty
|
|
// and no document is created — that's correct behavior.
|
|
//
|
|
// We check published_state to know what to expect.
|
|
let final_state = engine.get_skill_state(skill_id);
|
|
let has_published_state = final_state
|
|
.as_ref()
|
|
.map(|s| !s.state.is_empty())
|
|
.unwrap_or(false);
|
|
eprintln!(
|
|
" Skill has published state: {} ({} keys)",
|
|
has_published_state,
|
|
final_state.as_ref().map(|s| s.state.len()).unwrap_or(0)
|
|
);
|
|
|
|
if has_published_state {
|
|
assert!(
|
|
after_docs.len() > before_count,
|
|
"Expected at least one new document in namespace '{namespace}' after sync, \
|
|
but found {} (before: {before_count}). Published state is non-empty, \
|
|
so store_skill_sync should have been called.",
|
|
after_docs.len()
|
|
);
|
|
eprintln!(" PASS: Memory document created after sync");
|
|
} else {
|
|
eprintln!(" NOTE: Skill has no published state — no memory write expected");
|
|
eprintln!(" (This is correct behavior; persist_state_to_memory skips empty state)");
|
|
}
|
|
|
|
// ── Cleanup ──
|
|
let _ = engine.stop_skill(skill_id).await;
|
|
eprintln!("\n=== sync_rpc_persists_to_memory COMPLETE ===\n");
|
|
}
|
|
|
|
/// Test that `skill/tick` also persists state to memory.
|
|
#[tokio::test]
|
|
async fn tick_persists_to_memory() {
|
|
let _lock = env_lock();
|
|
let _ = env_logger::builder()
|
|
.filter_level(log::LevelFilter::Debug)
|
|
.is_test(true)
|
|
.try_init();
|
|
|
|
let skills_dir = require_skills_dir!();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let data_dir = tmp.path().join("skills_data");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(&data_dir).unwrap();
|
|
std::fs::create_dir_all(&workspace_dir).unwrap();
|
|
|
|
let skill_id = &select_skill_id(&skills_dir);
|
|
|
|
eprintln!("\n=== tick_persists_to_memory ===");
|
|
eprintln!(" Skill ID: {skill_id}");
|
|
|
|
let (engine, memory_client) =
|
|
create_engine_with_memory(&skills_dir, &data_dir, &workspace_dir).await;
|
|
|
|
// Start skill
|
|
let snap = engine.start_skill(skill_id).await.expect("start");
|
|
eprintln!(" Started: {:?}", snap.status);
|
|
|
|
// Call skill/tick
|
|
let tick_result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
engine.rpc(skill_id, "skill/tick", json!({})),
|
|
)
|
|
.await;
|
|
|
|
match &tick_result {
|
|
Ok(Ok(val)) => eprintln!(" skill/tick returned: {val}"),
|
|
Ok(Err(e)) => eprintln!(" skill/tick error: {e}"),
|
|
Err(_) => panic!("skill/tick TIMED OUT"),
|
|
}
|
|
|
|
// Wait for persistence
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
|
|
// Check memory
|
|
let namespace = format!("skill-{}", skill_id);
|
|
let docs = memory_client
|
|
.list_documents(Some(&namespace))
|
|
.await
|
|
.expect("list_documents");
|
|
let doc_count = docs
|
|
.get("documents")
|
|
.and_then(|d| d.as_array())
|
|
.map(|a| a.len())
|
|
.unwrap_or(0);
|
|
eprintln!(" Documents in '{namespace}' after tick: {doc_count}");
|
|
|
|
let has_published_state = engine
|
|
.get_skill_state(skill_id)
|
|
.map(|s| !s.state.is_empty())
|
|
.unwrap_or(false);
|
|
|
|
if has_published_state {
|
|
assert!(
|
|
doc_count > 0,
|
|
"Expected memory documents after tick with published state"
|
|
);
|
|
eprintln!(" PASS: Memory persisted after tick");
|
|
} else {
|
|
eprintln!(" NOTE: No published state — no memory write expected");
|
|
}
|
|
|
|
let _ = engine.stop_skill(skill_id).await;
|
|
eprintln!("=== tick_persists_to_memory COMPLETE ===\n");
|
|
}
|
|
|
|
/// Verify that the `skills_sync` RPC schema routes to `skill/sync` (not `skill/tick`).
|
|
/// This is a regression test for the routing bug where `handle_skills_sync` sent
|
|
/// `"skill/tick"` instead of `"skill/sync"`.
|
|
#[tokio::test]
|
|
async fn skills_sync_rpc_calls_on_sync_not_on_tick() {
|
|
let _lock = env_lock();
|
|
let _ = env_logger::builder()
|
|
.filter_level(log::LevelFilter::Debug)
|
|
.is_test(true)
|
|
.try_init();
|
|
|
|
let skills_dir = require_skills_dir!();
|
|
let tmp = tempdir().expect("tempdir");
|
|
let data_dir = tmp.path().join("skills_data");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(&data_dir).unwrap();
|
|
std::fs::create_dir_all(&workspace_dir).unwrap();
|
|
|
|
let skill_id = &select_skill_id(&skills_dir);
|
|
|
|
eprintln!("\n=== skills_sync_rpc_calls_on_sync_not_on_tick ===");
|
|
eprintln!(" Skill ID: {skill_id}");
|
|
|
|
let (engine, memory_client) =
|
|
create_engine_with_memory(&skills_dir, &data_dir, &workspace_dir).await;
|
|
|
|
let snap = engine.start_skill(skill_id).await.expect("start");
|
|
eprintln!(" Started: {:?}", snap.status);
|
|
|
|
// Exercise the full controller path via try_invoke_registered_rpc so we
|
|
// verify handle_skills_sync routes to "skill/sync" (not "skill/tick").
|
|
// This catches regressions in the controller layer that engine.rpc() would bypass.
|
|
let rpc_params: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(json!({"skill_id": skill_id})).unwrap();
|
|
let sync_result = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
try_invoke_registered_rpc("openhuman.skills_sync", rpc_params),
|
|
)
|
|
.await;
|
|
|
|
match &sync_result {
|
|
Ok(Some(Ok(val))) => {
|
|
eprintln!(" openhuman.skills_sync returned: {val}");
|
|
// The result comes from handle_js_call("onSync") which returns the
|
|
// JS value (typically null/undefined). The old buggy path returned
|
|
// {"ok": true} from handle_js_void_call("onTick").
|
|
}
|
|
Ok(Some(Err(e))) => {
|
|
eprintln!(" openhuman.skills_sync error: {e}");
|
|
// An error from onSync not being defined is still acceptable —
|
|
// the important thing is it tried onSync, not onTick.
|
|
}
|
|
Ok(None) => {
|
|
panic!("openhuman.skills_sync not found in registered controllers");
|
|
}
|
|
Err(_) => panic!("openhuman.skills_sync TIMED OUT"),
|
|
}
|
|
|
|
// Also verify the namespace gets a title with "periodic sync" (not "tick sync")
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
|
|
let namespace = format!("skill-{}", skill_id);
|
|
let docs = memory_client
|
|
.list_documents(Some(&namespace))
|
|
.await
|
|
.expect("list_documents");
|
|
if let Some(doc_array) = docs.get("documents").and_then(|d| d.as_array()) {
|
|
for doc in doc_array {
|
|
let title = doc.get("title").and_then(|t| t.as_str()).unwrap_or("?");
|
|
eprintln!(" Memory doc title: {title}");
|
|
assert!(
|
|
title.contains("periodic sync"),
|
|
"Expected title to contain 'periodic sync' (from skill/sync handler), got: {title}"
|
|
);
|
|
}
|
|
}
|
|
|
|
let _ = engine.stop_skill(skill_id).await;
|
|
eprintln!("=== skills_sync_rpc_calls_on_sync_not_on_tick COMPLETE ===\n");
|
|
}
|