hermes-agent/tests/tools/test_kanban_comment_injection.py
Brooklyn Nicholson 901205420f feat(kanban): talk to a running worker without a restart
A running worker now polls its comment thread and folds new operator notes
into the live turn via the OUT-OF-BAND steer channel (list_comments_after +
a heartbeat-driven bridge, watermarked so history isn't re-injected and the
worker's own notes are skipped). No block→comment→unblock dance. Desktop's
composer sends notes live ("delivered within a few seconds") with "Requeue
with note" as the restart option and a help tooltip.
2026-07-30 07:18:08 -05:00

124 lines
3.7 KiB
Python

"""Live operator-note injection into a running kanban worker.
``tools.kanban_tools.inject_new_comments_from_env`` polls the worker's task
for comments added *after* the run started and folds them into the live turn
via the agent's OUT-OF-BAND steer channel — so a user can talk to a running
task without the block→comment→unblock dance or a restart.
Verifies: no-op off a worker, watermark seeding (history isn't re-injected),
new comments steer, and own-authored comments are skipped.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
import tools.kanban_tools as kt
class FakeAgent:
def __init__(self):
self.steers: list[str] = []
def steer(self, text: str) -> bool:
self.steers.append(text)
return True
@pytest.fixture
def worker_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
# Reset module-level poll state so tests don't leak into each other.
kt._comment_watermark.clear()
kt._comment_poll_last_attempt = 0.0
return home
def _unthrottle():
"""Bypass the inter-poll rate limit for deterministic tests."""
kt._comment_poll_last_attempt = 0.0
def test_noop_without_worker_env(worker_home, monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
agent = FakeAgent()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []
def test_seed_then_inject_new_comment(worker_home, monkeypatch):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="live task")
kb.add_comment(conn, tid, author="desktop", body="pre-existing note")
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
agent = FakeAgent()
# First poll seeds the watermark past the existing thread — no injection.
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []
conn = kb.connect()
try:
kb.add_comment(conn, tid, author="desktop", body="actually use the v2 API")
finally:
conn.close()
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is True
assert len(agent.steers) == 1
assert "v2 API" in agent.steers[0]
# Watermark advanced — a re-poll with no new comments injects nothing.
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert len(agent.steers) == 1
def test_skips_own_authored_comments(worker_home, monkeypatch):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="echo guard")
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
agent = FakeAgent()
_unthrottle()
kt.inject_new_comments_from_env(agent) # seed
conn = kb.connect()
try:
kb.add_comment(conn, tid, author="worker-bot", body="i did a thing")
finally:
conn.close()
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []