Major changes: - Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required - Runtime engine field: runtime.engine selects opencode/claude from config - Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd) - Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json - Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command - Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification - Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help - /config set for editing config.json from chat with dotted key notation - Security audit saved to docs/security-audit.md - Full command reference in docs/commands.md - Future changes doc with NanoClaw agent teams analysis - Logo added to README and WebChat UI - README fully rewritten with all features documented
406 lines
13 KiB
Python
406 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Aetheel — Full Feature Test Script (cross-platform)
|
|
====================================================
|
|
Runs all tests and smoke checks for every Aetheel feature.
|
|
Works on Windows, Linux, and macOS.
|
|
|
|
Usage:
|
|
uv run python test_all.py
|
|
# or
|
|
python test_all.py
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
SKIP = 0
|
|
|
|
|
|
def _pass(msg):
|
|
global PASS
|
|
PASS += 1
|
|
print(f" ✅ {msg}")
|
|
|
|
|
|
def _fail(msg):
|
|
global FAIL
|
|
FAIL += 1
|
|
print(f" ❌ {msg}")
|
|
|
|
|
|
def _skip(msg):
|
|
global SKIP
|
|
SKIP += 1
|
|
print(f" ⚠️ {msg}")
|
|
|
|
|
|
def section(title):
|
|
print(f"\n━━━ {title} ━━━")
|
|
|
|
|
|
def run_cmd(args, cwd=None, timeout=120):
|
|
"""Run a command and return (returncode, stdout)."""
|
|
try:
|
|
result = subprocess.run(
|
|
args, capture_output=True, text=True,
|
|
cwd=cwd, timeout=timeout,
|
|
)
|
|
return result.returncode, result.stdout + result.stderr
|
|
except FileNotFoundError:
|
|
return -1, f"Command not found: {args[0]}"
|
|
except subprocess.TimeoutExpired:
|
|
return -1, "Timeout"
|
|
|
|
|
|
# =========================================================================
|
|
section("1. Environment Check")
|
|
# =========================================================================
|
|
|
|
# Python version
|
|
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
if sys.version_info >= (3, 12):
|
|
_pass(f"Python {py_ver} (>= 3.12 required)")
|
|
else:
|
|
_fail(f"Python {py_ver} — need >= 3.12")
|
|
|
|
# uv
|
|
if shutil.which("uv"):
|
|
rc, out = run_cmd(["uv", "--version"])
|
|
_pass(f"uv found: {out.strip()}")
|
|
else:
|
|
_skip("uv not found — using pip directly")
|
|
|
|
# Working directory
|
|
if os.path.isfile("pyproject.toml"):
|
|
_pass("Running from Aetheel directory")
|
|
else:
|
|
_fail("pyproject.toml not found — run from the Aetheel/ directory")
|
|
sys.exit(1)
|
|
|
|
|
|
# =========================================================================
|
|
section("2. Dependency Check")
|
|
# =========================================================================
|
|
|
|
required = {
|
|
"pytest": "pytest",
|
|
"pytest_asyncio": "pytest-asyncio",
|
|
"hypothesis": "hypothesis",
|
|
"click": "click",
|
|
"aiohttp": "aiohttp",
|
|
"apscheduler": "apscheduler",
|
|
"dotenv": "python-dotenv",
|
|
}
|
|
|
|
for mod_name, pkg_name in required.items():
|
|
try:
|
|
importlib.import_module(mod_name)
|
|
_pass(f"{pkg_name} installed")
|
|
except ImportError:
|
|
_fail(f"{pkg_name} NOT installed — run: uv sync --extra test")
|
|
|
|
|
|
# =========================================================================
|
|
section("3. Pytest — Unit Tests")
|
|
# =========================================================================
|
|
|
|
print(" Running full test suite...")
|
|
rc, out = run_cmd(
|
|
[sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short",
|
|
"--ignore=tests/test_scheduler.py"],
|
|
timeout=120,
|
|
)
|
|
# Print last 20 lines
|
|
lines = out.strip().splitlines()
|
|
for line in lines[-20:]:
|
|
print(f" {line}")
|
|
|
|
if rc == 0:
|
|
_pass("Core test suite passed")
|
|
else:
|
|
_fail("Core test suite had failures")
|
|
|
|
# Scheduler tests
|
|
print("\n Attempting scheduler tests...")
|
|
rc2, out2 = run_cmd(
|
|
[sys.executable, "-m", "pytest", "tests/test_scheduler.py", "-v", "--tb=short"],
|
|
timeout=30,
|
|
)
|
|
if rc2 == 0:
|
|
_pass("Scheduler tests passed")
|
|
else:
|
|
_skip("Scheduler tests skipped (apscheduler import issue)")
|
|
|
|
|
|
# =========================================================================
|
|
section("4. Config System")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from config import load_config, AetheelConfig, MCPConfig, MCPServerConfig, write_mcp_config
|
|
from config import HeartbeatConfig, WebChatConfig, HooksConfig, WebhookConfig
|
|
|
|
cfg = load_config()
|
|
assert isinstance(cfg, AetheelConfig)
|
|
assert cfg.claude.no_tools is False
|
|
assert len(cfg.claude.allowed_tools) > 0
|
|
assert hasattr(cfg, "heartbeat")
|
|
assert hasattr(cfg, "webchat")
|
|
assert hasattr(cfg, "mcp")
|
|
assert hasattr(cfg, "hooks")
|
|
assert hasattr(cfg, "webhooks")
|
|
_pass("Config loads with all sections (heartbeat, webchat, mcp, hooks, webhooks)")
|
|
except Exception as e:
|
|
_fail(f"Config system: {e}")
|
|
|
|
# MCP config writer
|
|
try:
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
mcp = MCPConfig(servers={
|
|
"test": MCPServerConfig(command="echo", args=["hi"], env={"K": "V"})
|
|
})
|
|
write_mcp_config(mcp, tmpdir, use_claude=True)
|
|
with open(os.path.join(tmpdir, ".mcp.json")) as f:
|
|
data = json.load(f)
|
|
assert "mcpServers" in data and "test" in data["mcpServers"]
|
|
|
|
write_mcp_config(mcp, tmpdir, use_claude=False)
|
|
with open(os.path.join(tmpdir, "opencode.json")) as f:
|
|
data = json.load(f)
|
|
assert "mcp" in data
|
|
_pass("MCP config writer (Claude + OpenCode formats)")
|
|
except Exception as e:
|
|
_fail(f"MCP config writer: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("5. System Prompt")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from agent.opencode_runtime import build_aetheel_system_prompt
|
|
prompt = build_aetheel_system_prompt()
|
|
for s in ["Your Tools", "Self-Modification", "Subagents & Teams"]:
|
|
assert s in prompt, f"Missing: {s}"
|
|
_pass("System prompt contains all new sections")
|
|
except Exception as e:
|
|
_fail(f"System prompt: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("6. CLI")
|
|
# =========================================================================
|
|
|
|
rc, out = run_cmd([sys.executable, "cli.py", "--help"])
|
|
if rc == 0:
|
|
_pass("CLI --help works")
|
|
else:
|
|
_fail("CLI --help failed")
|
|
|
|
for cmd in ["start", "chat", "status", "doctor"]:
|
|
rc, _ = run_cmd([sys.executable, "cli.py", cmd, "--help"])
|
|
if rc == 0:
|
|
_pass(f"CLI command '{cmd}' exists")
|
|
else:
|
|
_fail(f"CLI command '{cmd}' missing")
|
|
|
|
for grp in ["cron", "config", "memory"]:
|
|
rc, _ = run_cmd([sys.executable, "cli.py", grp, "--help"])
|
|
if rc == 0:
|
|
_pass(f"CLI group '{grp}' exists")
|
|
else:
|
|
_fail(f"CLI group '{grp}' missing")
|
|
|
|
|
|
# =========================================================================
|
|
section("7. Heartbeat Parser")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from heartbeat.heartbeat import HeartbeatRunner
|
|
tests = [
|
|
("Every 30 minutes", "*/30 * * * *"),
|
|
("Every hour", "0 * * * *"),
|
|
("Every 2 hours", "0 */2 * * *"),
|
|
("Every morning (9:00 AM)", "0 9 * * *"),
|
|
("Every evening (6:00 PM)", "0 18 * * *"),
|
|
]
|
|
for header, expected in tests:
|
|
result = HeartbeatRunner._parse_schedule_header(header)
|
|
assert result == expected, f"{header}: got {result}"
|
|
_pass(f"Schedule parser: all {len(tests)} patterns correct")
|
|
except Exception as e:
|
|
_fail(f"Heartbeat parser: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("8. Hook System")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from hooks.hooks import HookManager, HookEvent
|
|
|
|
mgr = HookManager()
|
|
received = []
|
|
mgr.register("gateway:startup", lambda e: received.append(e.event_key))
|
|
mgr.trigger(HookEvent(type="gateway", action="startup"))
|
|
assert received == ["gateway:startup"]
|
|
_pass("Programmatic hook register + trigger")
|
|
|
|
mgr2 = HookManager()
|
|
mgr2.register("test:event", lambda e: e.messages.append("hello"))
|
|
event = HookEvent(type="test", action="event")
|
|
msgs = mgr2.trigger(event)
|
|
assert "hello" in msgs
|
|
_pass("Hook message passing")
|
|
|
|
# File-based hook discovery
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
hook_dir = os.path.join(tmpdir, "hooks", "test-hook")
|
|
os.makedirs(hook_dir)
|
|
with open(os.path.join(hook_dir, "HOOK.md"), "w") as f:
|
|
f.write("---\nname: test-hook\nevents: [gateway:startup]\n---\n# Test\n")
|
|
with open(os.path.join(hook_dir, "handler.py"), "w") as f:
|
|
f.write("def handle(event):\n event.messages.append('file-hook-fired')\n")
|
|
mgr3 = HookManager(workspace_dir=tmpdir)
|
|
hooks = mgr3.discover()
|
|
assert len(hooks) == 1
|
|
event3 = HookEvent(type="gateway", action="startup")
|
|
msgs3 = mgr3.trigger(event3)
|
|
assert "file-hook-fired" in msgs3
|
|
_pass("File-based hook discovery + execution")
|
|
except Exception as e:
|
|
_fail(f"Hook system: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("9. Webhook Receiver")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from webhooks.receiver import WebhookReceiver, WebhookConfig as WHConfig
|
|
|
|
config = WHConfig(enabled=True, port=0, host="127.0.0.1", token="test")
|
|
receiver = WebhookReceiver(
|
|
ai_handler_fn=lambda msg: "ok",
|
|
send_fn=lambda *a: None,
|
|
config=config,
|
|
)
|
|
# Check routes exist
|
|
route_info = [str(r) for r in receiver._app.router.routes()]
|
|
has_wake = any("wake" in r for r in route_info)
|
|
has_agent = any("agent" in r for r in route_info)
|
|
has_health = any("health" in r for r in route_info)
|
|
assert has_wake and has_agent and has_health
|
|
_pass("Webhook routes registered (wake, agent, health)")
|
|
|
|
# Auth check
|
|
assert receiver._check_auth(type("R", (), {"headers": {"Authorization": "Bearer test"}, "query": {}})())
|
|
assert not receiver._check_auth(type("R", (), {"headers": {}, "query": {}})())
|
|
_pass("Webhook bearer token auth works")
|
|
except Exception as e:
|
|
_fail(f"Webhook receiver: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("10. SubagentBus")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from agent.subagent import SubagentBus, SubagentManager
|
|
|
|
bus = SubagentBus()
|
|
received = []
|
|
bus.subscribe("ch1", lambda msg, sender: received.append((msg, sender)))
|
|
bus.publish("ch1", "hello", "agent-1")
|
|
assert received == [("hello", "agent-1")]
|
|
_pass("SubagentBus pub/sub")
|
|
|
|
mgr = SubagentManager(runtime_factory=lambda: None, send_fn=lambda *a: None)
|
|
assert isinstance(mgr.bus, SubagentBus)
|
|
_pass("SubagentManager.bus property")
|
|
except Exception as e:
|
|
_fail(f"SubagentBus: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("11. WebChat Adapter")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from adapters.webchat_adapter import WebChatAdapter
|
|
from adapters.base import BaseAdapter
|
|
|
|
adapter = WebChatAdapter(host="127.0.0.1", port=9999)
|
|
assert isinstance(adapter, BaseAdapter)
|
|
assert adapter.source_name == "webchat"
|
|
_pass("WebChatAdapter extends BaseAdapter, source_name='webchat'")
|
|
|
|
assert os.path.isfile(os.path.join("static", "chat.html"))
|
|
_pass("static/chat.html exists")
|
|
except Exception as e:
|
|
_fail(f"WebChat adapter: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("12. Claude Runtime Config")
|
|
# =========================================================================
|
|
|
|
try:
|
|
from agent.claude_runtime import ClaudeCodeConfig
|
|
|
|
cfg = ClaudeCodeConfig()
|
|
assert cfg.no_tools is False
|
|
assert len(cfg.allowed_tools) >= 15
|
|
assert "Bash" in cfg.allowed_tools
|
|
assert "TeamCreate" in cfg.allowed_tools
|
|
assert "SendMessage" in cfg.allowed_tools
|
|
_pass(f"ClaudeCodeConfig: no_tools=False, {len(cfg.allowed_tools)} tools, Team tools included")
|
|
except Exception as e:
|
|
_fail(f"Claude runtime config: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("13. Module Imports")
|
|
# =========================================================================
|
|
|
|
modules = [
|
|
"config", "adapters.base", "adapters.webchat_adapter",
|
|
"agent.opencode_runtime", "agent.claude_runtime", "agent.subagent",
|
|
"heartbeat.heartbeat", "hooks.hooks", "webhooks.receiver",
|
|
"skills.skills", "cli",
|
|
]
|
|
for mod in modules:
|
|
try:
|
|
importlib.import_module(mod)
|
|
_pass(f"import {mod}")
|
|
except Exception as e:
|
|
_fail(f"import {mod}: {e}")
|
|
|
|
|
|
# =========================================================================
|
|
section("RESULTS")
|
|
# =========================================================================
|
|
|
|
total = PASS + FAIL + SKIP
|
|
print(f"\nTotal: {total} checks")
|
|
print(f" Passed: {PASS}")
|
|
print(f" Failed: {FAIL}")
|
|
print(f" Skipped: {SKIP}")
|
|
print()
|
|
|
|
if FAIL == 0:
|
|
print("All checks passed! 🎉")
|
|
sys.exit(0)
|
|
else:
|
|
print(f"{FAIL} check(s) failed.")
|
|
sys.exit(1)
|