forked from Zakaria/hermes-agent
Feature: Digital pet
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""Test helper for importing plugin modules from a hyphenated directory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_module(filename: str, module_name: str, package: bool = False):
|
||||
path = ROOT / filename
|
||||
if package:
|
||||
pkg_name = "digital_pet_pkg"
|
||||
if pkg_name not in sys.modules:
|
||||
pkg_spec = importlib.util.spec_from_file_location(pkg_name, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)])
|
||||
pkg = importlib.util.module_from_spec(pkg_spec)
|
||||
sys.modules[pkg_name] = pkg
|
||||
full_name = f"{pkg_name}.{path.stem}"
|
||||
else:
|
||||
full_name = module_name
|
||||
spec = importlib.util.spec_from_file_location(full_name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[full_name] = mod
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_dashboard_overlay_cleans_timers_and_ignores_late_fetches():
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
(async function () {{
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const events = [];
|
||||
const cleanups = [];
|
||||
let registeredComponent = null;
|
||||
let stateValue = {{ enabled: true, mode: 'idle', mood: 'calm', name: 'Signal', revision: 1 }};
|
||||
let errorWrites = 0;
|
||||
let stateWrites = 0;
|
||||
|
||||
function makeElement(type, props, ...children) {{
|
||||
return {{ type, props: props || {{}}, children }};
|
||||
}}
|
||||
const React = {{
|
||||
createElement: makeElement,
|
||||
useState(initial) {{
|
||||
if (initial === null) {{
|
||||
const setter = function (value) {{ stateWrites += 1; stateValue = value; }};
|
||||
return [stateValue, setter];
|
||||
}}
|
||||
const setter = function () {{ errorWrites += 1; }};
|
||||
return [initial, setter];
|
||||
}},
|
||||
useRef(initial) {{ return {{ current: initial }}; }},
|
||||
useEffect(fn) {{
|
||||
const cleanup = fn();
|
||||
if (cleanup) cleanups.push(cleanup);
|
||||
}},
|
||||
}};
|
||||
const windowObj = {{
|
||||
__HERMES_PLUGIN_SDK__: {{
|
||||
React,
|
||||
hooks: React,
|
||||
authedFetch(url, options) {{
|
||||
if (options && options.method === 'POST') {{
|
||||
events.push(JSON.parse(options.body).type);
|
||||
}}
|
||||
return Promise.resolve({{
|
||||
ok: true,
|
||||
json: () => Promise.resolve({{ ok: true, state: stateValue }}),
|
||||
}});
|
||||
}},
|
||||
}},
|
||||
__HERMES_PLUGINS__: {{
|
||||
registerSlot(plugin, slot, component) {{ registeredComponent = component; }}
|
||||
}},
|
||||
setTimeout(fn, delay) {{
|
||||
const id = nextTimer++;
|
||||
timers.set(id, {{ fn, delay }});
|
||||
return id;
|
||||
}},
|
||||
clearTimeout(id) {{ timers.delete(id); }},
|
||||
setInterval(fn, delay) {{
|
||||
const id = nextTimer++;
|
||||
timers.set(id, {{ fn, delay, interval: true }});
|
||||
return id;
|
||||
}},
|
||||
clearInterval(id) {{ timers.delete(id); }},
|
||||
}};
|
||||
const context = {{ window: windowObj, fetch: windowObj.__HERMES_PLUGIN_SDK__.authedFetch, console }};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({str(plugin_root / 'dashboard/dist/index.js')!r}, 'utf8'), context);
|
||||
assert.equal(typeof registeredComponent, 'function');
|
||||
|
||||
const tree = registeredComponent();
|
||||
const avatar = tree.children[1];
|
||||
avatar.props.onMouseEnter();
|
||||
avatar.props.onMouseEnter();
|
||||
const hoverTimers = Array.from(timers.values()).filter(t => t.delay === 1000);
|
||||
assert.equal(hoverTimers.length, 1, 'duplicate hover enters should leave one hover timer');
|
||||
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
assert.equal(timers.size, 0, 'unmount should clear interval, hover, and transition timers');
|
||||
|
||||
avatar.props.onClick();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(events, ['click']);
|
||||
assert.equal(stateWrites, 0, 'late fetch resolution after unmount must not write state');
|
||||
assert.equal(errorWrites, 0, 'late fetch resolution after unmount must not write errors');
|
||||
}})();
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(["node", "-e", script], text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from digital_pet_import import load_module
|
||||
|
||||
|
||||
def test_register_adds_pet_command_and_observer_hooks():
|
||||
plugin = load_module("__init__.py", "digital_pet_pkg", package=True)
|
||||
|
||||
class Ctx:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
self.hooks = []
|
||||
self.skills = []
|
||||
|
||||
def register_command(self, *args, **kwargs):
|
||||
self.commands.append((args, kwargs))
|
||||
|
||||
def register_hook(self, *args, **kwargs):
|
||||
self.hooks.append((args, kwargs))
|
||||
|
||||
def register_skill(self, *args, **kwargs):
|
||||
self.skills.append((args, kwargs))
|
||||
|
||||
ctx = Ctx()
|
||||
plugin.register(ctx)
|
||||
|
||||
assert ctx.commands[0][0][0] == "pet"
|
||||
assert ctx.commands[0][1]["description"].startswith("Summon")
|
||||
assert ctx.commands[0][1]["args_hint"] == "[summon|hide|wake|sleep|status|click]"
|
||||
assert [entry[0][0] for entry in ctx.hooks] == ["on_session_start", "post_tool_call"]
|
||||
|
||||
|
||||
def test_pet_command_status_and_unknown_do_not_throw(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_DIGITAL_PET_STATE", str(tmp_path / "state.json"))
|
||||
commands = load_module("commands.py", "digital_pet_pkg", package=True)
|
||||
|
||||
status = json.loads(commands.handle_pet_command("status"))
|
||||
unknown = json.loads(commands.handle_pet_command("nonsense"))
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["state"]["mode"] == "hidden"
|
||||
assert unknown == {
|
||||
"ok": False,
|
||||
"error": "unknown pet command",
|
||||
"usage": "/pet [summon|hide|wake|sleep|status|click]",
|
||||
}
|
||||
|
||||
|
||||
def test_pet_command_persists_summon_and_hide(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_DIGITAL_PET_STATE", str(tmp_path / "state.json"))
|
||||
commands = load_module("commands.py", "digital_pet_pkg", package=True)
|
||||
|
||||
summoned = json.loads(commands.handle_pet_command("summon"))
|
||||
hidden = json.loads(commands.handle_pet_command("hide"))
|
||||
|
||||
assert summoned["ok"] is True
|
||||
assert summoned["state"]["mode"] == "idle"
|
||||
assert hidden["ok"] is True
|
||||
assert hidden["state"]["mode"] == "hidden"
|
||||
assert (tmp_path / "state.json").exists()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from digital_pet_import import load_module
|
||||
|
||||
state_mod = load_module("state.py", "digital_pet_state")
|
||||
PetState = state_mod.PetState
|
||||
apply_event = state_mod.apply_event
|
||||
|
||||
|
||||
def at(second: int = 0):
|
||||
return datetime(2026, 1, 1, 12, 0, second, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_click_summons_hidden_pet_deterministically():
|
||||
state = PetState()
|
||||
|
||||
next_state = apply_event("click", state=state, now=at())
|
||||
|
||||
assert next_state.enabled is True
|
||||
assert next_state.mode == "idle"
|
||||
assert next_state.last_mode == "hidden"
|
||||
assert next_state.message == "Station companion online."
|
||||
assert next_state.revision == 1
|
||||
|
||||
|
||||
def test_hover_hold_sends_active_pet_to_sleep():
|
||||
state = apply_event("summon", state=PetState(), now=at())
|
||||
|
||||
next_state = apply_event("hover_hold", state=state, now=at(1))
|
||||
|
||||
assert next_state.enabled is True
|
||||
assert next_state.mode == "sleeping"
|
||||
assert next_state.last_mode == "idle"
|
||||
assert next_state.message == "Going quiet."
|
||||
|
||||
|
||||
def test_sleeping_click_wakes_then_wake_complete_returns_idle():
|
||||
state = apply_event("summon", state=PetState(), now=at())
|
||||
state = apply_event("hover_hold", state=state, now=at(1))
|
||||
|
||||
waking = apply_event("click", state=state, now=at(2))
|
||||
assert waking.mode == "waking"
|
||||
assert waking.message == "Back online."
|
||||
|
||||
idle = apply_event("wake_complete", state=waking, now=at(3))
|
||||
assert idle.mode == "idle"
|
||||
assert idle.message is None
|
||||
|
||||
|
||||
def test_reaction_and_prompt_complete_return_to_idle():
|
||||
state = apply_event("summon", state=PetState(), now=at())
|
||||
reacting = apply_event("click", state=state, now=at(1))
|
||||
assert reacting.mode == "reacting"
|
||||
|
||||
idle = apply_event("reaction_complete", state=reacting, now=at(2))
|
||||
assert idle.mode == "idle"
|
||||
|
||||
idle.prompt_cooldown_until = None
|
||||
prompting = apply_event("prompt_tick", state=idle, now=at(3))
|
||||
assert prompting.mode == "prompting"
|
||||
|
||||
completed = apply_event("prompt_complete", state=prompting, now=at(4))
|
||||
assert completed.mode == "idle"
|
||||
|
||||
|
||||
def test_hidden_and_sleeping_ignore_prompt_and_reacting_completion():
|
||||
hidden = PetState()
|
||||
after_prompt = apply_event("prompt_tick", state=hidden, now=at())
|
||||
after_reaction_complete = apply_event("reaction_complete", state=after_prompt, now=at(1))
|
||||
|
||||
assert after_prompt.mode == "hidden"
|
||||
assert after_reaction_complete.mode == "hidden"
|
||||
|
||||
sleeping = apply_event("sleep", state=PetState(enabled=True, mode="idle"), now=at(2))
|
||||
still_sleeping = apply_event("prompt_tick", state=sleeping, now=at(3))
|
||||
|
||||
assert still_sleeping.mode == "sleeping"
|
||||
Reference in New Issue
Block a user