mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(skills): debug infrastructure + disconnect credential cleanup (#154)
* feat(debug): add skills debug script and E2E tests - Introduced a new script `debug-skill.sh` for running end-to-end tests on skills, allowing users to easily test specific skills with customizable parameters. - Added comprehensive integration tests in `skills_debug_e2e.rs` to validate the full lifecycle of skills, including discovery, starting, tool listing, and execution. - Enhanced logging and error handling in the tests to improve observability and debugging capabilities. These additions facilitate better testing and debugging of skills, improving the overall development workflow. * feat(tests): add end-to-end tests for Skills RPC over HTTP JSON-RPC - Introduced a new test file `skills_rpc_e2e.rs` to validate the full stack of skill operations via HTTP JSON-RPC. - Implemented comprehensive tests covering skill discovery, starting, tool listing, and execution, ensuring robust functionality. - Enhanced logging for better observability during test execution, facilitating easier debugging and validation of skill interactions. These tests improve the reliability and maintainability of the skills framework by ensuring all critical operations are thoroughly validated. * refactor(tests): update RPC method names in end-to-end tests for skills - Changed RPC method names in `skills_rpc_e2e.rs` to use the new `openhuman` prefix, reflecting the updated API structure. - Updated corresponding test assertions to ensure consistency with the new method names. - Enhanced logging messages to align with the new method naming conventions, improving clarity during test execution. These changes ensure that the end-to-end tests accurately reflect the current API and improve maintainability. * feat(debug): add live debugging script and corresponding tests for Notion skill - Introduced `debug-notion-live.sh` script to facilitate debugging of the Notion skill with a live backend, including health checks and OAuth proxy testing. - Added `skills_notion_live.rs` test file to validate the Notion skill's functionality using real data and backend interactions. - Enhanced logging and error handling in both the script and tests to improve observability and debugging capabilities. These additions streamline the debugging process and ensure the Notion skill operates correctly with live data. * feat(env): enhance environment configuration for debugging scripts - Updated `.env.example` to include a new `JWT_TOKEN` variable for session management in debugging scripts. - Modified `debug-notion-live.sh` and `debug-skill.sh` scripts to load environment variables from `.env`, improving flexibility and usability. - Enhanced error handling in the scripts to ensure required variables are set, providing clearer feedback during execution. These changes streamline the debugging process for skills by ensuring necessary configurations are easily managed and accessible. * feat(tests): add disconnect flow test for skills - Introduced a new end-to-end test `skill_disconnect_flow` to validate the disconnect process for skills, mirroring the expected frontend behavior. - The test covers the stopping of a skill, handling OAuth credentials, and verifying cleanup after a disconnect. - Enhanced logging throughout the test to improve observability and debugging capabilities. These additions ensure that the disconnect flow is properly validated, improving the reliability of skill interactions. * fix(skills): revoke OAuth credentials on skill disconnect disconnectSkill() was only stopping the skill and resetting setup_complete, leaving oauth_credential.json on disk. On restart the stale credential would be restored, causing confusing auth state. Now sends oauth/revoked RPC before stopping so the event loop deletes the credential file and clears memory. Also adds revokeOAuth() and disableSkill() to the skills RPC API layer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply cargo fmt to skill debug tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(tests): improve skills directory discovery and error handling - Renamed `find_skills_dir` to `try_find_skills_dir`, returning an `Option<PathBuf>` to handle cases where the skills directory is not found. - Introduced a macro `require_skills_dir!` to simplify the usage of skills directory discovery in tests, providing clearer error messages when the directory is unavailable. - Updated multiple test functions to utilize the new macro, enhancing readability and maintainability of the test code. These changes improve the robustness of the skills directory discovery process and streamline the test setup. * refactor(tests): enhance skills directory discovery with improved error handling - Renamed `find_skills_dir` to `try_find_skills_dir`, returning an `Option<PathBuf>` to better handle cases where the skills directory is not found. - Introduced a new macro `require_skills_dir!` to streamline the usage of skills directory discovery in tests, providing clearer error messages when the directory is unavailable. - Updated test functions to utilize the new macro, improving code readability and maintainability. These changes enhance the robustness of the skills directory discovery process and simplify test setup. * fix(tests): skip skill tests gracefully when skills dir unavailable Tests that require the openhuman-skills repo now return early with a SKIPPED message instead of panicking when the directory is not found. Fixes CI failures where the skills repo is not checked out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(skills): harden disconnect flow, test assertions, and secret redaction - disconnectSkill: read stored credentialId from snapshot and pass it to oauth/revoked for correct memory bucket cleanup; add host-side fallback to delete oauth_credential.json when the runtime is already stopped. - revokeOAuth: make integrationId required (no more "default" fabrication); add removePersistedOAuthCredential helper for host-side cleanup. - skills_debug_e2e: hard-assert oauth_credential.json is deleted after oauth/revoked instead of soft logging. - skills_notion_live: gate behind RUN_LIVE_NOTION=1; require all env vars (BACKEND_URL, JWT_TOKEN, CREDENTIAL_ID, SKILLS_DATA_DIR); redact JWT and credential file contents from logs. - skills_rpc_e2e: check_result renamed to assert_rpc_ok and now panics on JSON-RPC errors so protocol regressions fail fast. - debug-notion-live.sh: capture cargo exit code separately from grep/head to avoid spurious failures under set -euo pipefail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply cargo fmt to skills_notion_live.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a1932408dd
commit
00c7b01280
@@ -12,6 +12,14 @@ BACKEND_URL=https://staging-api.alphahuman.xyz
|
||||
# [required] Also read by Vite frontend (VITE_ prefix required for browser exposure)
|
||||
VITE_BACKEND_URL=https://staging-api.alphahuman.xyz
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication (for skills OAuth proxy and debug scripts)
|
||||
# ---------------------------------------------------------------------------
|
||||
# [optional] Session JWT — used by QuickJS skills sandbox for oauth.fetch proxy calls.
|
||||
# Also used by debug scripts (scripts/debug-skill.sh, scripts/debug-notion-live.sh).
|
||||
# Get from login flow or browser devtools.
|
||||
JWT_TOKEN=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core process
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
import { callCoreRpc } from "../../services/coreRpcClient";
|
||||
import { SkillRuntime } from "./runtime";
|
||||
import { emitSkillStateChange } from "./skillEvents";
|
||||
import { setSetupComplete as rpcSetSetupComplete } from "./skillsApi";
|
||||
import {
|
||||
getSkillSnapshot,
|
||||
setSetupComplete as rpcSetSetupComplete,
|
||||
revokeOAuth as rpcRevokeOAuth,
|
||||
removePersistedOAuthCredential,
|
||||
} from "./skillsApi";
|
||||
import { syncToolsToBackend } from "./sync";
|
||||
import type {
|
||||
SkillManifest,
|
||||
@@ -340,10 +345,53 @@ class SkillManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a skill — stop it and reset setup state.
|
||||
* Disconnect a skill — revoke OAuth credentials, stop it, and reset setup state.
|
||||
*/
|
||||
async disconnectSkill(skillId: string): Promise<void> {
|
||||
// Read the stored credential ID so oauth/revoked clears the right memory bucket.
|
||||
let credentialId: string | undefined;
|
||||
try {
|
||||
const snap = await getSkillSnapshot(skillId);
|
||||
const cred = snap?.state?.__oauth_credential as
|
||||
| { credentialId?: string }
|
||||
| string
|
||||
| undefined;
|
||||
if (cred && typeof cred === "object") {
|
||||
credentialId = cred.credentialId;
|
||||
}
|
||||
} catch {
|
||||
// Snapshot may fail if skill isn't registered yet
|
||||
}
|
||||
|
||||
// Revoke OAuth credential before stopping so the running skill can clean up
|
||||
// its in-memory state and the event loop deletes oauth_credential.json.
|
||||
let revokeSucceeded = false;
|
||||
if (credentialId) {
|
||||
try {
|
||||
await rpcRevokeOAuth(skillId, credentialId);
|
||||
revokeSucceeded = true;
|
||||
} catch (err) {
|
||||
console.debug(
|
||||
"[SkillManager] oauth/revoked failed (runtime may be stopped):",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.stopSkill(skillId);
|
||||
|
||||
// Host-side fallback: if the RPC couldn't reach the runtime (already stopped,
|
||||
// or non-OAuth skill), delete the persisted credential file so it isn't
|
||||
// restored on next start.
|
||||
if (!revokeSucceeded) {
|
||||
await removePersistedOAuthCredential(skillId).catch((err) => {
|
||||
console.debug(
|
||||
"[SkillManager] host-side credential cleanup failed:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await rpcSetSetupComplete(skillId, false).catch(() => {});
|
||||
emitSkillStateChange(skillId);
|
||||
syncToolsToBackend();
|
||||
|
||||
@@ -118,6 +118,35 @@ export async function setSetupComplete(skillId: string, complete: boolean): Prom
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeOAuth(skillId: string, integrationId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_rpc',
|
||||
params: {
|
||||
skill_id: skillId,
|
||||
method: 'oauth/revoked',
|
||||
params: { integrationId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-side fallback: delete oauth_credential.json from the skill's data dir.
|
||||
* Used when the runtime is already stopped so oauth/revoked RPC can't reach it.
|
||||
*/
|
||||
export async function removePersistedOAuthCredential(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_data_write',
|
||||
params: { skill_id: skillId, filename: 'oauth_credential.json', content: '' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableSkill(skillId: string): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_disable',
|
||||
params: { skill_id: skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRegistryFresh(): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.skills_registry_fetch',
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# debug-notion-live.sh — Debug Notion skill with a live backend + JWT.
|
||||
#
|
||||
# Loads environment from .env (BACKEND_URL, JWT_TOKEN, etc.)
|
||||
#
|
||||
# Tests the full OAuth proxy chain that the Notion skill uses:
|
||||
# 1. Raw HTTP call to backend proxy endpoint
|
||||
# 2. Skill startup with BACKEND_URL + session token
|
||||
# 3. Tool call that uses oauth.fetch (proxied through backend)
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/debug-notion-live.sh
|
||||
#
|
||||
# Environment variables (set in .env or override via export):
|
||||
# BACKEND_URL — staging or prod backend
|
||||
# JWT_TOKEN — session JWT
|
||||
# CREDENTIAL_ID — Notion OAuth credential ID
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Load .env
|
||||
if [ -f "$REPO_ROOT/.env" ]; then
|
||||
source "$SCRIPT_DIR/load-dotenv.sh" "$REPO_ROOT/.env"
|
||||
fi
|
||||
|
||||
BACKEND_URL="${BACKEND_URL:-}"
|
||||
JWT_TOKEN="${JWT_TOKEN:-}"
|
||||
CREDENTIAL_ID="${CREDENTIAL_ID:-}"
|
||||
|
||||
# Read credential ID from oauth_credential.json if not set
|
||||
if [ -z "$CREDENTIAL_ID" ]; then
|
||||
CRED_FILE="$HOME/.openhuman/skills_data/notion/oauth_credential.json"
|
||||
if [ -f "$CRED_FILE" ]; then
|
||||
CREDENTIAL_ID=$(python3 -c "import json; print(json.load(open('$CRED_FILE')).get('credentialId',''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$BACKEND_URL" ]; then
|
||||
echo "ERROR: BACKEND_URL not set. Add it to .env or export it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$JWT_TOKEN" ]; then
|
||||
echo "ERROR: JWT_TOKEN not set. Add it to .env or export it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════╗"
|
||||
echo "║ Notion Skill Live Debug ║"
|
||||
echo "╠════════════════════════════════════════════════════════╣"
|
||||
echo "║ Backend: $BACKEND_URL"
|
||||
echo "║ Credential ID: ${CREDENTIAL_ID:-<not found>}"
|
||||
echo "║ JWT: ${JWT_TOKEN:0:20}..."
|
||||
echo "╚════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Check backend health ──
|
||||
echo "--- Step 1: Backend Health Check ---"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BACKEND_URL/settings" -H "Authorization: Bearer $JWT_TOKEN" 2>/dev/null || echo "000")
|
||||
echo " GET /settings → HTTP $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "000" ] || [ "$HTTP_CODE" = "502" ] || [ "$HTTP_CODE" = "503" ]; then
|
||||
echo " ✗ Backend is DOWN (HTTP $HTTP_CODE)"
|
||||
echo ""
|
||||
echo " The backend at $BACKEND_URL is unreachable."
|
||||
echo " The Notion skill uses oauth.fetch() which proxies through:"
|
||||
echo " $BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/{path}"
|
||||
echo ""
|
||||
echo " Fix: Bring the backend online, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$HTTP_CODE" = "401" ]; then
|
||||
echo " ✗ JWT is invalid or expired (HTTP 401)"
|
||||
echo " Get a fresh JWT and set JWT_TOKEN in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ Backend reachable (HTTP $HTTP_CODE)"
|
||||
|
||||
# ── Step 2: Raw proxy call ──
|
||||
if [ -n "$CREDENTIAL_ID" ]; then
|
||||
echo ""
|
||||
echo "--- Step 2: Raw OAuth Proxy Call ---"
|
||||
echo " Testing: GET $BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/v1/users?page_size=1"
|
||||
PROXY_RESP=$(curl -s -w "\n__HTTP_CODE__:%{http_code}" \
|
||||
"$BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/v1/users?page_size=1" \
|
||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" 2>/dev/null || echo "__HTTP_CODE__:000")
|
||||
|
||||
PROXY_BODY=$(echo "$PROXY_RESP" | sed '/__HTTP_CODE__/d')
|
||||
PROXY_CODE=$(echo "$PROXY_RESP" | grep "__HTTP_CODE__" | cut -d: -f2)
|
||||
|
||||
echo " HTTP $PROXY_CODE"
|
||||
if [ "$PROXY_CODE" = "200" ]; then
|
||||
echo " ✓ Notion API accessible via proxy"
|
||||
echo " Response: ${PROXY_BODY:0:200}..."
|
||||
else
|
||||
echo " ✗ Proxy returned HTTP $PROXY_CODE"
|
||||
echo " Response: $PROXY_BODY"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "--- Step 2: SKIPPED (no CREDENTIAL_ID) ---"
|
||||
fi
|
||||
|
||||
# ── Step 3: Test via Rust runtime ──
|
||||
echo ""
|
||||
echo "--- Step 3: Skill Runtime Test (with live backend) ---"
|
||||
|
||||
export SKILL_DEBUG_ID=notion
|
||||
export SKILL_DEBUG_TOOL=sync-status
|
||||
export RUST_LOG="${RUST_LOG:-info}"
|
||||
|
||||
STEP3_OUT=$(cargo test --test skills_debug_e2e skill_full_lifecycle -- --nocapture 2>&1) || true
|
||||
STEP3_RC=${PIPESTATUS[0]:-$?}
|
||||
echo "$STEP3_OUT" | grep -E "(✓|✗|·|---|====|Text:|Result:)" | head -40 || true
|
||||
if [ "$STEP3_RC" -ne 0 ]; then
|
||||
echo " ✗ cargo test exited with code $STEP3_RC"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Step 4: Notion Live Test (real data dir) ---"
|
||||
echo ""
|
||||
STEP4_OUT=$(RUN_LIVE_NOTION=1 cargo test --test skills_notion_live -- --nocapture 2>&1) || true
|
||||
STEP4_RC=${PIPESTATUS[0]:-$?}
|
||||
echo "$STEP4_OUT" | grep -E "(✓|✗|---|Step|Backend|OAuth|HTTP|status|connected|workspace|totals|Result:|is_error|Done|COMPLETE)" | head -30 || true
|
||||
if [ "$STEP4_RC" -ne 0 ]; then
|
||||
echo " ✗ cargo test exited with code $STEP4_RC"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# debug-skill.sh — Run the skills debug E2E test against a real skill.
|
||||
#
|
||||
# Loads environment from .env (BACKEND_URL, JWT_TOKEN, etc.)
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/debug-skill.sh # test example-skill (auto-find dir)
|
||||
# bash scripts/debug-skill.sh gmail # test a specific skill
|
||||
# bash scripts/debug-skill.sh gmail /path/to/skills # explicit skills dir
|
||||
# bash scripts/debug-skill.sh gmail "" get-emails '{"query":"test"}'
|
||||
#
|
||||
# Environment variables (set in .env or override via export):
|
||||
# BACKEND_URL — backend API URL
|
||||
# JWT_TOKEN — session JWT for OAuth proxy
|
||||
# SKILL_DEBUG_ID — skill ID (default: example-skill)
|
||||
# SKILL_DEBUG_DIR — path to skills dir containing skill folders
|
||||
# SKILL_DEBUG_TOOL — tool name to call (default: first tool)
|
||||
# SKILL_DEBUG_TOOL_ARGS — JSON args for the tool (default: "{}")
|
||||
# SKILL_DEBUG_VERBOSE — "1" for verbose logging
|
||||
# RUST_LOG — Rust log filter (default: info)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Load .env (won't overwrite vars already set in the shell)
|
||||
if [ -f "$REPO_ROOT/.env" ]; then
|
||||
source "$SCRIPT_DIR/load-dotenv.sh" "$REPO_ROOT/.env"
|
||||
fi
|
||||
|
||||
# Parse positional args
|
||||
SKILL_ID="${1:-${SKILL_DEBUG_ID:-example-skill}}"
|
||||
SKILLS_DIR="${2:-${SKILL_DEBUG_DIR:-}}"
|
||||
TOOL_NAME="${3:-${SKILL_DEBUG_TOOL:-}}"
|
||||
TOOL_ARGS="${4:-${SKILL_DEBUG_TOOL_ARGS:-}}"
|
||||
|
||||
export SKILL_DEBUG_ID="$SKILL_ID"
|
||||
[ -n "$SKILLS_DIR" ] && export SKILL_DEBUG_DIR="$SKILLS_DIR"
|
||||
[ -n "$TOOL_NAME" ] && export SKILL_DEBUG_TOOL="$TOOL_NAME"
|
||||
[ -n "$TOOL_ARGS" ] && export SKILL_DEBUG_TOOL_ARGS="$TOOL_ARGS"
|
||||
|
||||
# Default log level
|
||||
export RUST_LOG="${RUST_LOG:-info}"
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ Skills Debug Runner ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
echo "║ Skill: $SKILL_ID"
|
||||
echo "║ Skills dir: ${SKILL_DEBUG_DIR:-<auto-detect>}"
|
||||
echo "║ Tool: ${SKILL_DEBUG_TOOL:-<first available>}"
|
||||
echo "║ Tool args: ${SKILL_DEBUG_TOOL_ARGS:-{}}"
|
||||
echo "║ BACKEND_URL: ${BACKEND_URL:-<not set>}"
|
||||
echo "║ JWT_TOKEN: ${JWT_TOKEN:+${JWT_TOKEN:0:20}...}"
|
||||
echo "║ RUST_LOG: $RUST_LOG"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Run just the full lifecycle test by default, with output
|
||||
cargo test --test skills_debug_e2e skill_full_lifecycle -- --nocapture 2>&1
|
||||
|
||||
echo ""
|
||||
echo "Done. To run all skill tests (including edge cases):"
|
||||
echo " cargo test --test skills_debug_e2e -- --nocapture"
|
||||
@@ -0,0 +1,698 @@
|
||||
//! Skills runtime debug / integration tests.
|
||||
//!
|
||||
//! Exercises the full skill lifecycle through the RuntimeEngine:
|
||||
//! discover → start → list tools → call tool → setup flow → tick/sync → stop
|
||||
//!
|
||||
//! By default uses the bundled `example-skill` from the openhuman-skills repo.
|
||||
//! Override with env vars:
|
||||
//! SKILL_DEBUG_ID — skill ID to test (default: "example-skill")
|
||||
//! SKILL_DEBUG_DIR — path to skills directory containing skill folders
|
||||
//! SKILL_DEBUG_TOOL — specific tool name to call (default: first tool found)
|
||||
//! SKILL_DEBUG_TOOL_ARGS — JSON args for the tool call (default: "{}")
|
||||
//! SKILL_DEBUG_VERBOSE — set to "1" for extra output
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo test --test skills_debug_e2e -- --nocapture
|
||||
//! # or via the wrapper script:
|
||||
//! bash scripts/debug-skill.sh [skill-id] [skills-dir]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
fn is_verbose() -> bool {
|
||||
std::env::var("SKILL_DEBUG_VERBOSE")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn banner(label: &str) {
|
||||
let sep = "=".repeat(60);
|
||||
eprintln!("\n{sep}");
|
||||
eprintln!(" {label}");
|
||||
eprintln!("{sep}");
|
||||
}
|
||||
|
||||
fn step(label: &str) {
|
||||
eprintln!("\n--- {label} ---");
|
||||
}
|
||||
|
||||
fn ok(msg: &str) {
|
||||
eprintln!(" ✓ {msg}");
|
||||
}
|
||||
|
||||
fn fail(msg: &str) {
|
||||
eprintln!(" ✗ {msg}");
|
||||
}
|
||||
|
||||
fn info(msg: &str) {
|
||||
eprintln!(" · {msg}");
|
||||
}
|
||||
|
||||
/// Find the skills source directory. Returns None when not available (e.g. CI).
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. SKILL_DEBUG_DIR env var
|
||||
/// 2. ../openhuman-skills/skills (sibling repo)
|
||||
/// 3. openhuman-skills/skills (subdir)
|
||||
/// 4. Broader search in parent workspace
|
||||
fn try_find_skills_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") {
|
||||
let p = PathBuf::from(&dir);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
eprintln!("SKILL_DEBUG_DIR={dir} does not exist");
|
||||
return None;
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
let candidates = [
|
||||
cwd.join("../openhuman-skills/skills"),
|
||||
cwd.join("openhuman-skills/skills"),
|
||||
cwd.join("../alphahuman/skills/skills"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Search parent workspace
|
||||
if let Some(parent) = cwd.parent() {
|
||||
for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() {
|
||||
let candidate = entry.path().join("skills/skills");
|
||||
if candidate.exists() && candidate.join("example-skill/manifest.json").exists() {
|
||||
return Some(candidate.canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Convenience wrapper that skips the test when no skills directory is found.
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a RuntimeEngine with the given skills source dir and a temp data dir.
|
||||
async fn create_engine(skills_dir: &Path, data_dir: &Path) -> Arc<RuntimeEngine> {
|
||||
let engine =
|
||||
RuntimeEngine::new(data_dir.to_path_buf()).expect("RuntimeEngine::new should succeed");
|
||||
let engine = Arc::new(engine);
|
||||
|
||||
// Set the skills source directory
|
||||
engine.set_skills_source_dir(skills_dir.to_path_buf());
|
||||
|
||||
// Set as global so RPC handlers can find it
|
||||
set_global_engine(engine.clone());
|
||||
|
||||
engine
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full lifecycle test: discover → start → tools → call → setup → tick → stop
|
||||
#[tokio::test]
|
||||
async fn skill_full_lifecycle() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(if is_verbose() {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
})
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create data_dir");
|
||||
|
||||
banner(&format!("Skills Debug E2E — skill: {skill_id}"));
|
||||
info(&format!("Skills dir: {}", skills_dir.display()));
|
||||
info(&format!("Data dir: {}", data_dir.display()));
|
||||
|
||||
let engine = create_engine(&skills_dir, &data_dir).await;
|
||||
|
||||
// ── 1. Discover ──
|
||||
step("DISCOVER SKILLS");
|
||||
let manifests = engine.discover_skills().await;
|
||||
match &manifests {
|
||||
Ok(m) => {
|
||||
ok(&format!("Found {} skill(s)", m.len()));
|
||||
for manifest in m {
|
||||
info(&format!(
|
||||
" {} — {} (runtime: {}, auto_start: {})",
|
||||
manifest.id, manifest.name, manifest.runtime, manifest.auto_start
|
||||
));
|
||||
}
|
||||
|
||||
// Verify target skill exists
|
||||
let found = m.iter().any(|m| m.id == skill_id);
|
||||
if found {
|
||||
ok(&format!("Target skill '{skill_id}' found in discovery"));
|
||||
} else {
|
||||
fail(&format!(
|
||||
"Target skill '{skill_id}' NOT found. Available: {}",
|
||||
m.iter()
|
||||
.map(|m| m.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
panic!("Target skill not found in discovered skills");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&format!("Discovery failed: {e}"));
|
||||
panic!("Skill discovery failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Start ──
|
||||
step(&format!("START SKILL '{skill_id}'"));
|
||||
let start_result = engine.start_skill(&skill_id).await;
|
||||
match &start_result {
|
||||
Ok(snapshot) => {
|
||||
ok(&format!("Skill started — status: {:?}", snapshot.status));
|
||||
info(&format!(" Name: {}", snapshot.name));
|
||||
info(&format!(" Tools: {} registered", snapshot.tools.len()));
|
||||
for tool in &snapshot.tools {
|
||||
info(&format!(" - {} : {}", tool.name, tool.description));
|
||||
}
|
||||
if let Some(err) = &snapshot.error {
|
||||
fail(&format!(" Error: {err}"));
|
||||
}
|
||||
info(&format!(
|
||||
" Published state keys: {:?}",
|
||||
snapshot.state.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&format!("Start failed: {e}"));
|
||||
panic!("Skill start failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = start_result.unwrap();
|
||||
|
||||
// ── 3. List tools via RPC ──
|
||||
step("LIST TOOLS (via RPC)");
|
||||
let tools_result = engine.rpc(&skill_id, "tools/list", json!({})).await;
|
||||
match &tools_result {
|
||||
Ok(val) => {
|
||||
let tools = val.get("tools").and_then(|t| t.as_array());
|
||||
if let Some(tools) = tools {
|
||||
ok(&format!("{} tool(s) via RPC", tools.len()));
|
||||
for tool in tools {
|
||||
let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
|
||||
info(&format!(" - {name}"));
|
||||
}
|
||||
} else {
|
||||
ok(&format!("RPC result: {val}"));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&format!("tools/list RPC failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Call a tool ──
|
||||
step("CALL TOOL");
|
||||
let tool_name = env_or(
|
||||
"SKILL_DEBUG_TOOL",
|
||||
snapshot
|
||||
.tools
|
||||
.first()
|
||||
.map(|t| t.name.as_str())
|
||||
.unwrap_or("get-status"),
|
||||
);
|
||||
let tool_args: Value = std::env::var("SKILL_DEBUG_TOOL_ARGS")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
|
||||
info(&format!(
|
||||
"Calling tool '{tool_name}' with args: {tool_args}"
|
||||
));
|
||||
|
||||
let call_result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
engine.call_tool(&skill_id, &tool_name, tool_args.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
match call_result {
|
||||
Ok(Ok(result)) => {
|
||||
ok(&format!(
|
||||
"Tool call succeeded (is_error: {})",
|
||||
result.is_error
|
||||
));
|
||||
for content in &result.content {
|
||||
match content {
|
||||
openhuman_core::openhuman::skills::types::ToolContent::Text { text } => {
|
||||
info(&format!(" Text: {text}"));
|
||||
}
|
||||
openhuman_core::openhuman::skills::types::ToolContent::Json { data } => {
|
||||
info(&format!(" JSON: {data}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_error {
|
||||
fail("Tool returned is_error=true");
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
fail(&format!("Tool call error: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
fail("Tool call TIMED OUT (30s)");
|
||||
panic!("Tool call timed out");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4b. Call tool via RPC path (tools/call) ──
|
||||
step("CALL TOOL (via RPC)");
|
||||
let rpc_call_result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
engine.rpc(
|
||||
&skill_id,
|
||||
"tools/call",
|
||||
json!({ "name": tool_name, "arguments": tool_args }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
match rpc_call_result {
|
||||
Ok(Ok(val)) => {
|
||||
ok(&format!("RPC tools/call succeeded: {val}"));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
fail(&format!("RPC tools/call error: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
fail("RPC tools/call TIMED OUT (30s)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Setup flow ──
|
||||
step("SETUP FLOW");
|
||||
let setup_result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
engine.rpc(&skill_id, "setup/start", json!({})),
|
||||
)
|
||||
.await;
|
||||
|
||||
match setup_result {
|
||||
Ok(Ok(val)) => {
|
||||
ok(&format!("setup/start returned: {val}"));
|
||||
|
||||
// If there's a step with fields, try a submit with empty values
|
||||
if let Some(step_id) = val.get("stepId").and_then(|s| s.as_str()) {
|
||||
info(&format!("Got setup step: {step_id}"));
|
||||
|
||||
if let Some(fields) = val.get("fields").and_then(|f| f.as_array()) {
|
||||
info(&format!(
|
||||
" Fields: {}",
|
||||
fields
|
||||
.iter()
|
||||
.filter_map(|f| f.get("name").and_then(|n| n.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// Cancel setup (don't actually submit — we don't have valid creds)
|
||||
let cancel = engine.rpc(&skill_id, "setup/cancel", json!({})).await;
|
||||
match cancel {
|
||||
Ok(val) => ok(&format!("setup/cancel: {val}")),
|
||||
Err(e) => info(&format!("setup/cancel: {e} (may be fine)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
info(&format!(
|
||||
"setup/start returned error: {e} (expected if no setup handler)"
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
fail("setup/start TIMED OUT (10s)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Tick / Sync ──
|
||||
step("TICK (sync)");
|
||||
let tick_result = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
engine.rpc(&skill_id, "skill/tick", json!({})),
|
||||
)
|
||||
.await;
|
||||
|
||||
match tick_result {
|
||||
Ok(Ok(val)) => {
|
||||
ok(&format!("skill/tick returned: {val}"));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
info(&format!("skill/tick error: {e} (may be expected)"));
|
||||
}
|
||||
Err(_) => {
|
||||
fail("skill/tick TIMED OUT (15s)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Session lifecycle ──
|
||||
step("SESSION LIFECYCLE");
|
||||
let session_id = "debug-session-1";
|
||||
|
||||
let session_start = engine
|
||||
.rpc(
|
||||
&skill_id,
|
||||
"skill/sessionStart",
|
||||
json!({ "sessionId": session_id }),
|
||||
)
|
||||
.await;
|
||||
match session_start {
|
||||
Ok(val) => ok(&format!("sessionStart: {val}")),
|
||||
Err(e) => info(&format!("sessionStart: {e} (may be expected)")),
|
||||
}
|
||||
|
||||
let session_end = engine
|
||||
.rpc(
|
||||
&skill_id,
|
||||
"skill/sessionEnd",
|
||||
json!({ "sessionId": session_id }),
|
||||
)
|
||||
.await;
|
||||
match session_end {
|
||||
Ok(val) => ok(&format!("sessionEnd: {val}")),
|
||||
Err(e) => info(&format!("sessionEnd: {e} (may be expected)")),
|
||||
}
|
||||
|
||||
// ── 8. Skill state check ──
|
||||
step("SKILL STATE CHECK");
|
||||
let state = engine.get_skill_state(&skill_id);
|
||||
match state {
|
||||
Some(snap) => {
|
||||
ok(&format!("Status: {:?}", snap.status));
|
||||
info(&format!("Tools: {}", snap.tools.len()));
|
||||
info(&format!("Published state: {} key(s)", snap.state.len()));
|
||||
if is_verbose() {
|
||||
for (k, v) in &snap.state {
|
||||
info(&format!(" {k} = {v}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
fail("Skill not found in registry after start");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. List all tools (cross-skill) ──
|
||||
step("ALL TOOLS (cross-skill)");
|
||||
let all_tools = engine.all_tools();
|
||||
ok(&format!("{} tool(s) across all skills", all_tools.len()));
|
||||
for (skill, tool) in &all_tools {
|
||||
info(&format!(" {skill} :: {}", tool.name));
|
||||
}
|
||||
|
||||
// ── 10. Stop ──
|
||||
step(&format!("STOP SKILL '{skill_id}'"));
|
||||
let stop_result =
|
||||
tokio::time::timeout(Duration::from_secs(10), engine.stop_skill(&skill_id)).await;
|
||||
|
||||
match stop_result {
|
||||
Ok(Ok(())) => {
|
||||
ok("Skill stopped cleanly");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
fail(&format!("Stop failed: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
fail("Stop TIMED OUT (10s)");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify stopped
|
||||
let post_stop = engine.get_skill_state(&skill_id);
|
||||
match post_stop {
|
||||
Some(snap) => {
|
||||
info(&format!("Post-stop status: {:?}", snap.status));
|
||||
}
|
||||
None => {
|
||||
ok("Skill unregistered after stop");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 11. List skills (should show stopped/unregistered) ──
|
||||
step("LIST ALL SKILLS (post-stop)");
|
||||
let all = engine.list_skills();
|
||||
ok(&format!("{} skill(s) still registered", all.len()));
|
||||
for s in &all {
|
||||
info(&format!(" {} — {:?}", s.skill_id, s.status));
|
||||
}
|
||||
|
||||
banner("ALL CHECKS COMPLETE");
|
||||
}
|
||||
|
||||
/// Test that calling a tool on a non-existent skill gives a clear error.
|
||||
#[tokio::test]
|
||||
async fn skill_not_found_gives_clear_error() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Warn)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
let engine = RuntimeEngine::new(data_dir).expect("engine");
|
||||
let engine = Arc::new(engine);
|
||||
set_global_engine(engine.clone());
|
||||
|
||||
let result = engine
|
||||
.call_tool("nonexistent-skill", "some-tool", json!({}))
|
||||
.await;
|
||||
assert!(result.is_err(), "Expected error for non-existent skill");
|
||||
let err = result.unwrap_err();
|
||||
eprintln!(" Expected error: {err}");
|
||||
assert!(
|
||||
err.contains("not found") || err.contains("not registered") || err.contains("No skill"),
|
||||
"Error message should mention not found: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that discovering skills from an empty directory returns an empty list.
|
||||
#[tokio::test]
|
||||
async fn discover_empty_dir() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Warn)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("data");
|
||||
let skills_dir = tmp.path().join("skills");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
let engine = RuntimeEngine::new(data_dir).expect("engine");
|
||||
let engine = Arc::new(engine);
|
||||
engine.set_skills_source_dir(skills_dir);
|
||||
|
||||
let manifests = engine.discover_skills().await.expect("discover");
|
||||
assert!(
|
||||
manifests.is_empty(),
|
||||
"Expected empty list from empty skills dir, got {} manifests",
|
||||
manifests.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Stress test: start and stop the same skill rapidly.
|
||||
#[tokio::test]
|
||||
async fn skill_rapid_start_stop() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Warn)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
let engine = create_engine(&skills_dir, &data_dir).await;
|
||||
|
||||
for i in 0..3 {
|
||||
eprintln!(" Round {}/3: start", i + 1);
|
||||
let start = engine.start_skill(&skill_id).await;
|
||||
match &start {
|
||||
Ok(snap) => {
|
||||
eprintln!(" Started: {:?}, {} tools", snap.status, snap.tools.len());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" Start failed: {e}");
|
||||
// Don't panic — this tests resilience
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to let the event loop spin
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
eprintln!(" Round {}/3: stop", i + 1);
|
||||
let _ = engine.stop_skill(&skill_id).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
eprintln!(" Rapid start/stop completed without panic");
|
||||
}
|
||||
|
||||
/// Test disconnect flow: stop → oauth/revoked → verify credential cleaned up.
|
||||
/// Mirrors what the frontend *should* do when a user clicks "Disconnect".
|
||||
#[tokio::test]
|
||||
async fn skill_disconnect_flow() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let data_dir = tmp.path().join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
let engine = create_engine(&skills_dir, &data_dir).await;
|
||||
|
||||
// ── Start skill ──
|
||||
eprintln!("\n--- DISCONNECT TEST ---");
|
||||
eprintln!(" Starting skill '{skill_id}'...");
|
||||
let snap = engine.start_skill(&skill_id).await.expect("start");
|
||||
assert_eq!(
|
||||
snap.status,
|
||||
openhuman_core::openhuman::skills::types::SkillStatus::Running
|
||||
);
|
||||
eprintln!(" ✓ Running, {} tools", snap.tools.len());
|
||||
|
||||
// ── Write a fake OAuth credential ──
|
||||
let skill_data_dir = data_dir.join(&skill_id);
|
||||
std::fs::create_dir_all(&skill_data_dir).unwrap();
|
||||
let cred_path = skill_data_dir.join("oauth_credential.json");
|
||||
std::fs::write(
|
||||
&cred_path,
|
||||
r#"{"credentialId":"test-cred-123","provider":"test","grantedScopes":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
cred_path.exists(),
|
||||
"Credential file should exist before disconnect"
|
||||
);
|
||||
eprintln!(" ✓ Wrote fake oauth_credential.json");
|
||||
|
||||
// ── Simulate frontend disconnect: stop + set_setup_complete(false) ──
|
||||
eprintln!(" Simulating frontend disconnectSkill()...");
|
||||
|
||||
// Step 1: Stop (what frontend does)
|
||||
engine.stop_skill(&skill_id).await.expect("stop");
|
||||
eprintln!(" ✓ Skill stopped");
|
||||
|
||||
// Step 2: Reset setup_complete (what frontend does)
|
||||
engine.preferences().set_setup_complete(&skill_id, false);
|
||||
assert!(!engine.preferences().is_setup_complete(&skill_id));
|
||||
eprintln!(" ✓ setup_complete = false");
|
||||
|
||||
// ── Verify credential file still exists (BUG: disconnect doesn't clean it) ──
|
||||
let cred_still_exists = cred_path.exists();
|
||||
if cred_still_exists {
|
||||
eprintln!(" ⚠ oauth_credential.json still exists after disconnect (expected gap)");
|
||||
eprintln!(" → Frontend disconnect does NOT call oauth/revoked");
|
||||
eprintln!(" → On restart, the old credential will be restored");
|
||||
} else {
|
||||
eprintln!(" ✓ oauth_credential.json cleaned up");
|
||||
}
|
||||
|
||||
// ── Now test the proper flow: start + send oauth/revoked ──
|
||||
eprintln!("\n Testing proper disconnect (with oauth/revoked)...");
|
||||
// Re-write credential for the proper test
|
||||
std::fs::write(
|
||||
&cred_path,
|
||||
r#"{"credentialId":"test-cred-456","provider":"test","grantedScopes":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snap2 = engine.start_skill(&skill_id).await.expect("restart");
|
||||
assert_eq!(
|
||||
snap2.status,
|
||||
openhuman_core::openhuman::skills::types::SkillStatus::Running
|
||||
);
|
||||
eprintln!(" ✓ Restarted skill");
|
||||
|
||||
// Send oauth/revoked RPC (what disconnect SHOULD do)
|
||||
let revoke_result = engine
|
||||
.rpc(
|
||||
&skill_id,
|
||||
"oauth/revoked",
|
||||
json!({"integrationId": "test-cred-456"}),
|
||||
)
|
||||
.await;
|
||||
match &revoke_result {
|
||||
Ok(val) => eprintln!(" ✓ oauth/revoked returned: {val}"),
|
||||
Err(e) => eprintln!(" · oauth/revoked: {e} (may be expected for non-OAuth skills)"),
|
||||
}
|
||||
|
||||
// Now stop
|
||||
engine.stop_skill(&skill_id).await.expect("stop");
|
||||
engine.preferences().set_setup_complete(&skill_id, false);
|
||||
eprintln!(" ✓ Stopped + setup_complete = false");
|
||||
|
||||
// Verify credential file is gone after oauth/revoked
|
||||
assert!(
|
||||
!cred_path.exists(),
|
||||
"oauth_credential.json should be deleted after oauth/revoked but still exists at {}",
|
||||
cred_path.display()
|
||||
);
|
||||
eprintln!(" ✓ oauth_credential.json deleted after oauth/revoked");
|
||||
|
||||
// ── Verify restart after disconnect shows clean state ──
|
||||
eprintln!("\n Verifying clean restart after proper disconnect...");
|
||||
let snap3 = engine
|
||||
.start_skill(&skill_id)
|
||||
.await
|
||||
.expect("restart after disconnect");
|
||||
eprintln!(
|
||||
" ✓ Restarted: {:?}, {} tools",
|
||||
snap3.status,
|
||||
snap3.tools.len()
|
||||
);
|
||||
eprintln!(
|
||||
" setup_complete: {}",
|
||||
engine.preferences().is_setup_complete(&skill_id)
|
||||
);
|
||||
|
||||
engine.stop_skill(&skill_id).await.expect("final stop");
|
||||
eprintln!("\n ✓ Disconnect flow test complete");
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Notion skill live debug test — uses real data directory and backend.
|
||||
//!
|
||||
//! This test uses:
|
||||
//! - The real ~/.openhuman/skills_data/notion/ directory (with oauth_credential.json)
|
||||
//! - BACKEND_URL env var for the OAuth proxy
|
||||
//! - JWT_TOKEN env var for authentication
|
||||
//!
|
||||
//! Run:
|
||||
//! BACKEND_URL=https://staging-api.alphahuman.xyz \
|
||||
//! JWT_TOKEN=<jwt> \
|
||||
//! cargo test --test skills_notion_live -- --nocapture
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
|
||||
|
||||
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 };
|
||||
}
|
||||
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("notion/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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notion_live_with_real_data() {
|
||||
// Opt-in: only runs when RUN_LIVE_NOTION=1 is set explicitly.
|
||||
if std::env::var("RUN_LIVE_NOTION").unwrap_or_default() != "1" {
|
||||
eprintln!("SKIPPED: set RUN_LIVE_NOTION=1 to run this live integration test");
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let backend_url =
|
||||
std::env::var("BACKEND_URL").expect("BACKEND_URL must be set for live Notion test");
|
||||
let jwt_token = std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set for live Notion test");
|
||||
let credential_id =
|
||||
std::env::var("CREDENTIAL_ID").expect("CREDENTIAL_ID must be set for live Notion test");
|
||||
let skills_dir = require_skills_dir!();
|
||||
|
||||
let real_data_dir = PathBuf::from(
|
||||
std::env::var("SKILLS_DATA_DIR").expect("SKILLS_DATA_DIR must be set for live Notion test"),
|
||||
);
|
||||
|
||||
let sep = "=".repeat(60);
|
||||
eprintln!("\n{sep}");
|
||||
eprintln!(" Notion Live Debug (real data dir)");
|
||||
eprintln!("{sep}");
|
||||
eprintln!(" Backend: {backend_url}");
|
||||
eprintln!(" JWT: <redacted, {} bytes>", jwt_token.len());
|
||||
eprintln!(" Credential ID: {credential_id}");
|
||||
eprintln!(" Skills dir: {}", skills_dir.display());
|
||||
eprintln!(" Data dir: {}", real_data_dir.display());
|
||||
|
||||
// Check oauth_credential.json exists (don't log contents — may contain secrets)
|
||||
let cred_path = real_data_dir.join("notion/oauth_credential.json");
|
||||
if cred_path.exists() {
|
||||
let size = std::fs::metadata(&cred_path).map(|m| m.len()).unwrap_or(0);
|
||||
eprintln!(" OAuth cred: present ({size} bytes)");
|
||||
} else {
|
||||
eprintln!(" OAuth cred: NOT FOUND at {}", cred_path.display());
|
||||
eprintln!(" (Skill will start without OAuth — tools that need API access will fail)");
|
||||
}
|
||||
|
||||
// ── Step 1: Raw backend check ──
|
||||
eprintln!("\n--- Step 1: Backend Health ---");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let health = client
|
||||
.get(format!("{backend_url}/settings"))
|
||||
.header("Authorization", format!("Bearer {jwt_token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match health {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
eprintln!(" GET /settings → HTTP {status}");
|
||||
if status.is_success() {
|
||||
eprintln!(" ✓ Backend reachable, JWT valid");
|
||||
eprintln!(" Body: {}...", &body[..body.len().min(200)]);
|
||||
} else if status.as_u16() == 502 {
|
||||
eprintln!(" ✗ Backend DOWN (502 Bad Gateway)");
|
||||
eprintln!(" The staging server is unreachable. OAuth proxy will fail.");
|
||||
eprintln!(" Continuing to test skill lifecycle anyway...");
|
||||
} else {
|
||||
eprintln!(" ⚠ HTTP {status}: {body}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Connection failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Raw proxy check ──
|
||||
eprintln!("\n--- Step 2: OAuth Proxy Check ---");
|
||||
let proxy_url = format!("{backend_url}/proxy/by-id/{credential_id}/v1/users?page_size=1");
|
||||
eprintln!(" GET {proxy_url}");
|
||||
|
||||
let proxy = client
|
||||
.get(&proxy_url)
|
||||
.header("Authorization", format!("Bearer {jwt_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match proxy {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
eprintln!(" HTTP {status}");
|
||||
if status.is_success() {
|
||||
eprintln!(" ✓ Notion API accessible via proxy");
|
||||
eprintln!(" Body: {}...", &body[..body.len().min(300)]);
|
||||
} else {
|
||||
eprintln!(
|
||||
" ✗ Proxy returned {status}: {}...",
|
||||
&body[..body.len().min(200)]
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Proxy request failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Start skill with real data dir ──
|
||||
eprintln!("\n--- Step 3: Start Notion Skill (real data dir) ---");
|
||||
let engine = RuntimeEngine::new(real_data_dir.clone()).expect("engine");
|
||||
let engine = Arc::new(engine);
|
||||
engine.set_skills_source_dir(skills_dir.clone());
|
||||
set_global_engine(engine.clone());
|
||||
|
||||
let start = engine.start_skill("notion").await;
|
||||
match &start {
|
||||
Ok(snap) => {
|
||||
eprintln!(" ✓ Started — status: {:?}", snap.status);
|
||||
eprintln!(" Tools: {}", snap.tools.len());
|
||||
eprintln!(" Published state:");
|
||||
for (k, v) in &snap.state {
|
||||
if k.contains("status")
|
||||
|| k.contains("error")
|
||||
|| k.contains("auth")
|
||||
|| k == "workspaceName"
|
||||
|| k == "is_initialized"
|
||||
{
|
||||
eprintln!(" {k} = {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Start failed: {e}");
|
||||
panic!("Skill start failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: sync-status tool ──
|
||||
eprintln!("\n--- Step 4: sync-status tool ---");
|
||||
let sync_status = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
engine.call_tool("notion", "sync-status", json!({})),
|
||||
)
|
||||
.await;
|
||||
|
||||
match sync_status {
|
||||
Ok(Ok(result)) => {
|
||||
for content in &result.content {
|
||||
match content {
|
||||
openhuman_core::openhuman::skills::types::ToolContent::Text { text } => {
|
||||
// Parse and pretty-print
|
||||
if let Ok(v) = serde_json::from_str::<Value>(text) {
|
||||
eprintln!(" ✓ sync-status:");
|
||||
eprintln!(
|
||||
" connected: {}",
|
||||
v.get("connected").unwrap_or(&json!(null))
|
||||
);
|
||||
eprintln!(
|
||||
" workspace: {}",
|
||||
v.get("workspace_name").unwrap_or(&json!(null))
|
||||
);
|
||||
eprintln!(
|
||||
" last_sync: {}",
|
||||
v.get("last_sync_time").unwrap_or(&json!(null))
|
||||
);
|
||||
eprintln!(
|
||||
" last_sync_error: {}",
|
||||
v.get("last_sync_error").unwrap_or(&json!(null))
|
||||
);
|
||||
if let Some(totals) = v.get("totals") {
|
||||
eprintln!(" totals: {totals}");
|
||||
}
|
||||
} else {
|
||||
eprintln!(" ✓ Raw: {text}");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => eprintln!(" ✗ Error: {e}"),
|
||||
Err(_) => eprintln!(" ✗ TIMED OUT"),
|
||||
}
|
||||
|
||||
// ── Step 5: search tool (needs OAuth) ──
|
||||
eprintln!("\n--- Step 5: search tool (needs OAuth proxy) ---");
|
||||
let search = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
engine.call_tool("notion", "search", json!({"query": "test", "page_size": 3})),
|
||||
)
|
||||
.await;
|
||||
|
||||
match search {
|
||||
Ok(Ok(result)) => {
|
||||
eprintln!(" is_error: {}", result.is_error);
|
||||
for content in &result.content {
|
||||
match content {
|
||||
openhuman_core::openhuman::skills::types::ToolContent::Text { text } => {
|
||||
eprintln!(" Result: {}...", &text[..text.len().min(500)]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => eprintln!(" ✗ Error: {e}"),
|
||||
Err(_) => eprintln!(" ✗ TIMED OUT"),
|
||||
}
|
||||
|
||||
// ── Step 6: list-all-pages tool (needs OAuth) ──
|
||||
eprintln!("\n--- Step 6: list-all-pages (needs OAuth proxy) ---");
|
||||
let pages = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
engine.call_tool("notion", "list-all-pages", json!({"page_size": 3})),
|
||||
)
|
||||
.await;
|
||||
|
||||
match pages {
|
||||
Ok(Ok(result)) => {
|
||||
eprintln!(" is_error: {}", result.is_error);
|
||||
for content in &result.content {
|
||||
match content {
|
||||
openhuman_core::openhuman::skills::types::ToolContent::Text { text } => {
|
||||
eprintln!(" Result: {}...", &text[..text.len().min(500)]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => eprintln!(" ✗ Error: {e}"),
|
||||
Err(_) => eprintln!(" ✗ TIMED OUT"),
|
||||
}
|
||||
|
||||
// ── Step 7: Final state ──
|
||||
eprintln!("\n--- Step 7: Final Skill State ---");
|
||||
if let Some(snap) = engine.get_skill_state("notion") {
|
||||
eprintln!(" Status: {:?}", snap.status);
|
||||
for (k, v) in &snap.state {
|
||||
if k.contains("status")
|
||||
|| k.contains("error")
|
||||
|| k.contains("auth")
|
||||
|| k == "workspaceName"
|
||||
{
|
||||
eprintln!(" {k} = {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
eprintln!("\n--- Stop ---");
|
||||
let _ = engine.stop_skill("notion").await;
|
||||
eprintln!(" Done");
|
||||
|
||||
let sep = "=".repeat(60);
|
||||
eprintln!("\n{sep}");
|
||||
eprintln!(" COMPLETE");
|
||||
eprintln!("{sep}\n");
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Skills RPC E2E test — exercises skill operations over HTTP JSON-RPC.
|
||||
//!
|
||||
//! Tests the full stack: HTTP request → JSON-RPC dispatch → RuntimeEngine → QuickJS.
|
||||
//!
|
||||
//! Environment variables (same as skills_debug_e2e.rs):
|
||||
//! SKILL_DEBUG_ID — skill ID (default: "example-skill")
|
||||
//! SKILL_DEBUG_DIR — path to skills directory
|
||||
//! SKILL_DEBUG_TOOL — tool name to call
|
||||
//! SKILL_DEBUG_TOOL_ARGS — JSON args for tool call
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo test --test skills_rpc_e2e -- --nocapture
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::core::jsonrpc::build_core_http_router;
|
||||
use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn serve_on_ephemeral(
|
||||
app: Router,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
||||
) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
async fn rpc_call(base: &str, id: i64, method: &str, params: Value) -> Value {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.expect("client");
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let url = format!("{}/rpc", base.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"HTTP error {} for {}",
|
||||
resp.status(),
|
||||
method
|
||||
);
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("json for {method}: {e}"))
|
||||
}
|
||||
|
||||
fn assert_rpc_ok(resp: &Value, context: &str) -> Value {
|
||||
if let Some(err) = resp.get("error") {
|
||||
panic!("{context}: unexpected JSON-RPC error: {err}");
|
||||
}
|
||||
resp.get("result")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{context}: missing 'result' field in response: {resp}"))
|
||||
}
|
||||
|
||||
// ── Test ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_over_http_rpc() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let data_dir = home.join("skills_data");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
// Set up env for isolated config
|
||||
let openhuman_dir = home.join(".openhuman");
|
||||
std::fs::create_dir_all(&openhuman_dir).unwrap();
|
||||
std::fs::write(
|
||||
openhuman_dir.join("config.toml"),
|
||||
r#"api_url = "http://127.0.0.1:1"
|
||||
default_model = "test"
|
||||
[secrets]
|
||||
encrypt = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Unsafe env overrides (tests are serialized)
|
||||
unsafe {
|
||||
std::env::set_var("HOME", home.as_os_str());
|
||||
std::env::remove_var("OPENHUMAN_WORKSPACE");
|
||||
std::env::remove_var("BACKEND_URL");
|
||||
std::env::remove_var("VITE_BACKEND_URL");
|
||||
}
|
||||
|
||||
// Create and register engine
|
||||
let engine = Arc::new(RuntimeEngine::new(data_dir).expect("engine"));
|
||||
engine.set_skills_source_dir(skills_dir.clone());
|
||||
set_global_engine(engine.clone());
|
||||
|
||||
// Start the HTTP RPC server
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
eprintln!("\n=== Skills HTTP RPC E2E ===");
|
||||
eprintln!(" Skill: {skill_id}");
|
||||
eprintln!(" RPC: {base}");
|
||||
eprintln!(" Skills: {}", skills_dir.display());
|
||||
|
||||
// 1. core.ping
|
||||
eprintln!("\n--- core.ping ---");
|
||||
let ping = rpc_call(&base, 1, "core.ping", json!({})).await;
|
||||
let r = assert_rpc_ok(&ping, "core.ping");
|
||||
assert_eq!(r.get("ok"), Some(&json!(true)));
|
||||
eprintln!(" OK");
|
||||
|
||||
// 2. openhuman.skills_discover
|
||||
eprintln!("\n--- openhuman.skills_discover ---");
|
||||
let discover = rpc_call(&base, 2, "openhuman.skills_discover", json!({})).await;
|
||||
let r = assert_rpc_ok(&discover, "skills_discover");
|
||||
eprintln!(
|
||||
" Result: {} skills",
|
||||
r.as_array().map(|a| a.len()).unwrap_or(0)
|
||||
);
|
||||
|
||||
// 3. openhuman.skills_start
|
||||
eprintln!("\n--- openhuman.skills_start ---");
|
||||
let start = rpc_call(
|
||||
&base,
|
||||
3,
|
||||
"openhuman.skills_start",
|
||||
json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&start, "skills_start");
|
||||
eprintln!(" Status: {:?}", r.get("status"));
|
||||
eprintln!(
|
||||
" Tools: {}",
|
||||
r.get("tools")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
// 4. openhuman.skills_list_tools
|
||||
eprintln!("\n--- openhuman.skills_list_tools ---");
|
||||
let tools = rpc_call(
|
||||
&base,
|
||||
4,
|
||||
"openhuman.skills_list_tools",
|
||||
json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&tools, "skills_list_tools");
|
||||
let tool_list = r.get("tools").and_then(|t| t.as_array());
|
||||
if let Some(tools) = tool_list {
|
||||
eprintln!(" {} tools:", tools.len());
|
||||
for t in tools.iter().take(5) {
|
||||
eprintln!(
|
||||
" - {}",
|
||||
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||
);
|
||||
}
|
||||
if tools.len() > 5 {
|
||||
eprintln!(" ... and {} more", tools.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. openhuman.skills_call_tool
|
||||
eprintln!("\n--- openhuman.skills_call_tool ---");
|
||||
let tool_name = env_or("SKILL_DEBUG_TOOL", "get-status");
|
||||
let tool_args: Value = std::env::var("SKILL_DEBUG_TOOL_ARGS")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let call = rpc_call(
|
||||
&base,
|
||||
5,
|
||||
"openhuman.skills_call_tool",
|
||||
json!({ "skill_id": skill_id, "tool_name": tool_name, "arguments": tool_args }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&call, "skills_call_tool");
|
||||
eprintln!(" Result: {r}");
|
||||
|
||||
// 6. openhuman.skills_sync (tick)
|
||||
eprintln!("\n--- openhuman.skills_sync ---");
|
||||
let sync = rpc_call(
|
||||
&base,
|
||||
6,
|
||||
"openhuman.skills_sync",
|
||||
json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&sync, "skills_sync");
|
||||
eprintln!(" Result: {r}");
|
||||
|
||||
// 7. openhuman.skills_status
|
||||
eprintln!("\n--- openhuman.skills_status ---");
|
||||
let status = rpc_call(
|
||||
&base,
|
||||
7,
|
||||
"openhuman.skills_status",
|
||||
json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&status, "skills_status");
|
||||
eprintln!(" Status: {:?}", r.get("status"));
|
||||
eprintln!(
|
||||
" Published state keys: {:?}",
|
||||
r.get("state")
|
||||
.and_then(|s| s.as_object())
|
||||
.map(|o| o.keys().collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// 8. openhuman.skills_stop
|
||||
eprintln!("\n--- openhuman.skills_stop ---");
|
||||
let stop = rpc_call(
|
||||
&base,
|
||||
8,
|
||||
"openhuman.skills_stop",
|
||||
json!({ "skill_id": skill_id }),
|
||||
)
|
||||
.await;
|
||||
let r = assert_rpc_ok(&stop, "skills_stop");
|
||||
eprintln!(" Result: {r}");
|
||||
|
||||
// 9. openhuman.skills_list (post-stop)
|
||||
eprintln!("\n--- openhuman.skills_list (post-stop) ---");
|
||||
let list = rpc_call(&base, 9, "openhuman.skills_list", json!({})).await;
|
||||
let r = assert_rpc_ok(&list, "skills_list");
|
||||
let skills = r.as_array();
|
||||
eprintln!(" {} skill(s)", skills.map(|a| a.len()).unwrap_or(0));
|
||||
|
||||
eprintln!("\n=== Skills HTTP RPC E2E COMPLETE ===\n");
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user