Files
nousresearch--hermes-agent/tests/hermes_cli/test_kanban_decompose.py
T
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

348 lines
11 KiB
Python

"""Tests for the decomposer module + `hermes kanban decompose` CLI surface.
The auxiliary LLM client is mocked — no network calls. Tests exercise the
prompt plumbing, response parsing, DB writes (via the real DB helper),
and the assignee-fallback logic.
"""
from __future__ import annotations
import json as jsonlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_decompose as decomp
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
def _fake_aux_response(content: str):
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
return resp
def _mock_client_returning(content: str):
client = MagicMock()
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
return client
def _patch_aux_client(content: str, *, model: str = "test-model"):
client = _mock_client_returning(content)
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, model),
)
def _patch_extra_body():
return patch(
"agent.auxiliary_client.get_auxiliary_extra_body",
return_value={},
)
def _patch_list_profiles(names: list[str]):
"""Pretend the named profiles exist. The decomposer uses
profiles_mod.list_profiles() to build the roster + valid-set, and
profiles_mod.profile_exists() to resolve orchestrator/default."""
from types import SimpleNamespace
fake_profiles = [
SimpleNamespace(
name=n, is_default=(i == 0), description=f"desc for {n}",
description_auto=False, model="m", provider="p", skill_count=1,
)
for i, n in enumerate(names)
]
return [
patch("hermes_cli.profiles.list_profiles", return_value=fake_profiles),
patch("hermes_cli.profiles.profile_exists", side_effect=lambda x: x in names),
patch("hermes_cli.profiles.get_active_profile_name", return_value=names[0] if names else "default"),
]
def test_decompose_with_fanout_creates_children(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="ship a feature", triage=True)
llm_payload = jsonlib.dumps({
"fanout": True,
"rationale": "test split",
"tasks": [
{"title": "research", "body": "look it up", "assignee": "researcher", "parents": []},
{"title": "build", "body": "code it", "assignee": "engineer", "parents": [0]},
],
})
patches = _patch_list_profiles(["orchestrator", "researcher", "engineer"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body():
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.fanout is True
assert outcome.child_ids and len(outcome.child_ids) == 2
with kb.connect() as conn:
root = kb.get_task(conn, tid)
c0 = kb.get_task(conn, outcome.child_ids[0])
c1 = kb.get_task(conn, outcome.child_ids[1])
assert root.status == "todo"
assert c0.status == "ready"
assert c1.status == "todo"
assert c0.assignee == "researcher"
assert c1.assignee == "engineer"
def test_decompose_fanout_false_assigns_default_when_unassigned(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="just one thing", triage=True)
llm_payload = jsonlib.dumps({
"fanout": False,
"rationale": "single unit",
"title": "Tightened title",
"body": "**Goal**\nDo the thing.",
})
patches = _patch_list_profiles(["orchestrator", "fallback"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body(), patch(
"hermes_cli.kanban_decompose._load_config",
return_value={"kanban": {"default_assignee": "fallback"}},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.fanout is False
assert outcome.new_title == "Tightened title"
with kb.connect() as conn:
task = kb.get_task(conn, tid)
assert task is not None
# specify path with no parents -> recompute_ready flips to 'ready'
assert task.status == "ready"
assert task.title == "Tightened title"
assert task.assignee == "fallback"
def test_decompose_fanout_false_preserves_existing_assignee(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(
conn,
title="already routed",
assignee="engineer",
triage=True,
)
llm_payload = jsonlib.dumps({
"fanout": False,
"rationale": "single unit",
"title": "Tightened title",
"body": "Keep existing lane.",
"assignee": "fallback",
})
patches = _patch_list_profiles(["orchestrator", "engineer", "fallback"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body(), patch(
"hermes_cli.kanban_decompose._load_config",
return_value={"kanban": {"default_assignee": "fallback"}},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
with kb.connect() as conn:
task = kb.get_task(conn, tid)
assert task is not None
assert task.assignee == "engineer"
assert task.title == "Tightened title"
def test_decompose_fanout_false_uses_valid_llm_assignee(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="route me", triage=True)
llm_payload = jsonlib.dumps({
"fanout": False,
"rationale": "single unit",
"title": "Tightened title",
"body": "Route to specialist.",
"assignee": "engineer",
})
patches = _patch_list_profiles(["orchestrator", "engineer", "fallback"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body(), patch(
"hermes_cli.kanban_decompose._load_config",
return_value={"kanban": {"default_assignee": "fallback"}},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
with kb.connect() as conn:
task = kb.get_task(conn, tid)
assert task is not None
assert task.assignee == "engineer"
def test_decompose_fanout_false_invalid_llm_assignee_uses_default(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="route me safely", triage=True)
llm_payload = jsonlib.dumps({
"fanout": False,
"rationale": "single unit",
"title": "Tightened title",
"body": "Route to fallback.",
"assignee": "made_up",
})
patches = _patch_list_profiles(["orchestrator", "fallback"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body(), patch(
"hermes_cli.kanban_decompose._load_config",
return_value={"kanban": {"default_assignee": "fallback"}},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
with kb.connect() as conn:
task = kb.get_task(conn, tid)
assert task is not None
assert task.assignee == "fallback"
def test_decompose_unknown_assignee_falls_back_to_default(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
# Roster only has 'orchestrator' and 'fallback'; LLM picks 'made_up'.
llm_payload = jsonlib.dumps({
"fanout": True,
"rationale": "test",
"tasks": [
{"title": "do X", "body": "", "assignee": "made_up", "parents": []},
],
})
patches = _patch_list_profiles(["orchestrator", "fallback"])
for p in patches:
p.start()
try:
with patch.dict(
"os.environ", {}, clear=False,
), _patch_aux_client(llm_payload), _patch_extra_body(), \
patch(
"hermes_cli.kanban_decompose._load_config",
return_value={
"kanban": {
"orchestrator_profile": "orchestrator",
"default_assignee": "fallback",
}
},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.child_ids and len(outcome.child_ids) == 1
with kb.connect() as conn:
child = kb.get_task(conn, outcome.child_ids[0])
# 'made_up' wasn't in roster, so assignee rewritten to 'fallback'
assert child.assignee == "fallback"
def test_decompose_handles_malformed_llm_json(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
with _patch_aux_client("not json at all, sorry"), _patch_extra_body():
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "malformed JSON" in outcome.reason
def test_decompose_returns_false_when_task_not_triage(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x") # ready, not triage
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "not in triage" in outcome.reason
def test_decompose_no_aux_client_configured(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, ""),
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "no auxiliary client" in outcome.reason