mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Salvages #28199 by @bensargotest-sys. Aligns Kanban docs with current tool registration: dispatcher-spawned task workers get task tools, profiles that explicitly enable the kanban toolset get orchestrator routing tools (kanban_list, kanban_unblock). Corrects failure-limit text to current default of 2. Hardens the e2e subprocess script to resolve repo root and use the spawnable default assignee. Updates the diagnostics severity fixture to assert error below the critical threshold.
2144 lines
77 KiB
Python
2144 lines
77 KiB
Python
"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py).
|
|
|
|
The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app,
|
|
but here we attach its router to a bare FastAPI instance so we can test the
|
|
REST surface without spinning up the whole dashboard.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_plugin_router():
|
|
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
|
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"hermes_dashboard_plugin_kanban_test", plugin_file,
|
|
)
|
|
assert spec is not None and spec.loader is not None
|
|
mod = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = mod
|
|
spec.loader.exec_module(mod)
|
|
return mod.router
|
|
|
|
|
|
@pytest.fixture
|
|
def kanban_home(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME with an empty kanban DB."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def client(kanban_home):
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
return TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /board on an empty DB
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_empty(client):
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
# All canonical columns present (triage + the rest), each empty.
|
|
names = [c["name"] for c in data["columns"]]
|
|
assert set(names) == kb.VALID_STATUSES - {"archived"}
|
|
for expected in ("triage", "todo", "scheduled", "ready", "running", "blocked", "done"):
|
|
assert expected in names, f"missing column {expected}: {names}"
|
|
assert all(len(c["tasks"]) == 0 for c in data["columns"])
|
|
assert data["tenants"] == []
|
|
assert data["assignees"] == []
|
|
assert data["latest_event_id"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks then GET /board sees it
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_task_appears_on_board(client):
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={
|
|
"title": "Research LLM caching",
|
|
"assignee": "researcher",
|
|
"priority": 3,
|
|
"tenant": "acme",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
task = r.json()["task"]
|
|
assert task["title"] == "Research LLM caching"
|
|
assert task["assignee"] == "researcher"
|
|
assert task["status"] == "ready" # no parents -> immediately ready
|
|
assert task["priority"] == 3
|
|
assert task["tenant"] == "acme"
|
|
task_id = task["id"]
|
|
|
|
# Board now lists it under 'ready'.
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
ready = next(c for c in data["columns"] if c["name"] == "ready")
|
|
assert len(ready["tasks"]) == 1
|
|
assert ready["tasks"][0]["id"] == task_id
|
|
assert "acme" in data["tenants"]
|
|
assert "researcher" in data["assignees"]
|
|
|
|
|
|
def test_scheduled_tasks_have_their_own_column_not_todo(client):
|
|
"""Scheduled/time-delay tasks must not be silently bucketed into todo."""
|
|
|
|
task = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "wait for indexed data", "assignee": "ops"},
|
|
).json()["task"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
with kb.write_txn(conn):
|
|
conn.execute(
|
|
"UPDATE tasks SET status = 'scheduled' WHERE id = ?",
|
|
(task["id"],),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
columns = {c["name"]: c["tasks"] for c in r.json()["columns"]}
|
|
assert any(t["id"] == task["id"] for t in columns["scheduled"])
|
|
assert not any(t["id"] == task["id"] for t in columns["todo"])
|
|
|
|
|
|
def test_tenant_filter(client):
|
|
client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"})
|
|
client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"})
|
|
|
|
r = client.get("/api/plugins/kanban/board?tenant=t1")
|
|
counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]}
|
|
total = sum(counts.values())
|
|
assert total == 1
|
|
|
|
r = client.get("/api/plugins/kanban/board?tenant=t2")
|
|
total = sum(len(c["tasks"]) for c in r.json()["columns"])
|
|
assert total == 1
|
|
|
|
|
|
def test_board_query_param_default_overrides_current_board_pointer(client):
|
|
"""Dashboard ``?board=default`` must win even if the CLI's current-board
|
|
pointer targets a non-default board.
|
|
|
|
Regression: selecting the Default board in the dashboard must not fall
|
|
through to whichever board ``hermes kanban boards switch`` last pinned.
|
|
"""
|
|
default_task = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "default-only"},
|
|
).json()["task"]
|
|
|
|
kb.create_board("other")
|
|
other_conn = kb.connect(board="other")
|
|
try:
|
|
kb.create_task(other_conn, title="other-only")
|
|
finally:
|
|
other_conn.close()
|
|
|
|
kb.set_current_board("other")
|
|
|
|
current_board = client.get("/api/plugins/kanban/board").json()
|
|
current_ids = {
|
|
task["id"]
|
|
for column in current_board["columns"]
|
|
for task in column["tasks"]
|
|
}
|
|
assert default_task["id"] not in current_ids
|
|
|
|
pinned_default = client.get("/api/plugins/kanban/board?board=default").json()
|
|
pinned_ids = {
|
|
task["id"]
|
|
for column in pinned_default["columns"]
|
|
for task in column["tasks"]
|
|
}
|
|
assert pinned_ids == {default_task["id"]}
|
|
|
|
|
|
def test_dashboard_select_filters_use_sdk_value_change_handler():
|
|
"""Tenant/assignee filters must work with the dashboard SDK Select API.
|
|
|
|
The dashboard Select component is shadcn-like and calls
|
|
``onValueChange(value)`` instead of native ``onChange(event)``. A native-only
|
|
handler leaves the tenant dropdown visually selectable but never updates the
|
|
filtered board query.
|
|
"""
|
|
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
js = bundle.read_text()
|
|
|
|
assert "function selectChangeHandler(setter)" in js
|
|
assert "onValueChange: function (v)" in js
|
|
assert "onChange: function (e)" in js
|
|
assert "selectChangeHandler(props.setTenantFilter)" in js
|
|
assert "selectChangeHandler(props.setAssigneeFilter)" in js
|
|
|
|
|
|
def test_dashboard_client_side_filtering_includes_tenant_filter():
|
|
"""The rendered board must also filter by tenant.
|
|
|
|
The API request includes ``?tenant=...``, but the dashboard also filters the
|
|
locally cached board for search/assignee changes. Without checking
|
|
``tenantFilter`` here, switching tenants can leave stale cards visible until a
|
|
full reload finishes.
|
|
"""
|
|
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
js = bundle.read_text()
|
|
|
|
assert "if (tenantFilter && t.tenant !== tenantFilter) return false;" in js
|
|
assert "[boardData, tenantFilter, assigneeFilter, search]" in js
|
|
|
|
|
|
def test_dashboard_initial_board_uses_backend_current_when_unpinned():
|
|
"""Fresh browsers should open the backend current board, not default.
|
|
|
|
Explicit dashboard selections are stored in localStorage and should still
|
|
win, but an empty localStorage state must adopt the API's ``current`` board
|
|
so multi-board installs do not look empty on first load.
|
|
"""
|
|
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
js = bundle.read_text()
|
|
|
|
assert 'useState(() => readSelectedBoard() || null)' in js
|
|
assert "const storedBoard = readSelectedBoard();" in js
|
|
assert "if (!storedBoard && !board && data && data.current)" in js
|
|
assert "setBoard(data.current);" in js
|
|
assert 'readSelectedBoard() || "default"' not in js
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tasks/:id returns body + comments + events + links
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_task_detail_includes_links_and_events(client):
|
|
parent = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
|
).json()["task"]
|
|
child = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "child", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
assert child["status"] == "todo" # parent not done yet
|
|
|
|
# Detail for the child shows the parent link.
|
|
r = client.get(f"/api/plugins/kanban/tasks/{child['id']}")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["task"]["id"] == child["id"]
|
|
assert parent["id"] in data["links"]["parents"]
|
|
|
|
# Detail for the parent shows the child.
|
|
r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}")
|
|
assert child["id"] in r.json()["links"]["children"]
|
|
|
|
# Events exist from creation.
|
|
assert len(data["events"]) >= 1
|
|
|
|
|
|
def test_task_detail_404_on_unknown(client):
|
|
r = client.get("/api/plugins/kanban/tasks/does-not-exist")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tasks/:id — status transitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_patch_status_complete(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "done", "result": "shipped"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "done"
|
|
|
|
# Board reflects the move.
|
|
done = next(
|
|
c for c in client.get("/api/plugins/kanban/board").json()["columns"]
|
|
if c["name"] == "done"
|
|
)
|
|
assert any(x["id"] == t["id"] for x in done["tasks"])
|
|
|
|
|
|
def test_patch_block_then_unblock(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "blocked", "block_reason": "need input"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "blocked"
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "ready"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "ready"
|
|
|
|
|
|
def test_patch_drag_drop_move_todo_to_ready(client):
|
|
"""Direct status write: the drag-drop path for statuses without a
|
|
dedicated verb (e.g. manually promoting todo -> ready).
|
|
|
|
Promoting a child whose parent is not done is rejected (409).
|
|
Promoting a child whose parent IS done is accepted (200)."""
|
|
parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
|
|
child = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "c", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
assert child["status"] == "todo"
|
|
|
|
# Rejected: parent not done yet.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{child['id']}",
|
|
json={"status": "ready"},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
# The 409 detail must name the blocking parent so the dashboard can
|
|
# render an actionable toast instead of a silent no-op (#26744).
|
|
detail = r.json()["detail"]
|
|
assert "Cannot move to 'ready'" in detail
|
|
assert parent["id"] in detail
|
|
assert "'p'" in detail
|
|
assert "status=" in detail
|
|
# Whatever non-``done`` status the parent currently has must show up
|
|
# so the operator knows what to fix.
|
|
assert f"status={parent['status']}" in detail
|
|
assert parent["status"] != "done"
|
|
|
|
# Complete the parent.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{parent['id']}",
|
|
json={"status": "done"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
# Now child auto-promoted by recompute_ready — already ready.
|
|
child_after = client.get(f"/api/plugins/kanban/tasks/{child['id']}").json()["task"]
|
|
assert child_after["status"] == "ready"
|
|
|
|
|
|
def test_reopening_parent_demotes_ready_child(client):
|
|
"""Reopening a completed parent must invalidate ready children immediately.
|
|
|
|
The dispatcher re-checks parent completion on claim, but the dashboard
|
|
should not keep showing a stale child as ready after an operator drags
|
|
its parent back out of done for more work.
|
|
"""
|
|
parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
|
|
child = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "c", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
assert child["status"] == "todo"
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{parent['id']}",
|
|
json={"status": "done"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
child_after_done = client.get(
|
|
f"/api/plugins/kanban/tasks/{child['id']}"
|
|
).json()["task"]
|
|
assert child_after_done["status"] == "ready"
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{parent['id']}",
|
|
json={"status": "todo"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
child_after_reopen = client.get(
|
|
f"/api/plugins/kanban/tasks/{child['id']}"
|
|
).json()["task"]
|
|
assert child_after_reopen["status"] == "todo"
|
|
|
|
|
|
def test_patch_reassign(client):
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "x", "assignee": "a"},
|
|
).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"assignee": "b"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["assignee"] == "b"
|
|
|
|
|
|
def test_patch_priority_and_edit(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"priority": 5, "title": "renamed"},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()["task"]
|
|
assert data["priority"] == 5
|
|
assert data["title"] == "renamed"
|
|
|
|
|
|
def test_patch_invalid_status(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "banana"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_patch_status_running_rejected(client):
|
|
"""Dashboard PATCH cannot transition a task directly to 'running'.
|
|
|
|
The only legitimate path into 'running' is through the dispatcher's
|
|
``claim_task`` — which atomically creates a ``task_runs`` row,
|
|
claim_lock, expiry, and worker-PID metadata. Allowing a direct set
|
|
creates orphaned 'running' tasks with no run row or claim, which
|
|
violate the board's run-history invariants. See issue #19535.
|
|
"""
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "running"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "running" in r.json()["detail"]
|
|
# Task's status should still be its pre-request value — the direct-set
|
|
# was rejected before any mutation.
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
statuses = {
|
|
tt["id"]: col["name"]
|
|
for col in board["columns"]
|
|
for tt in col["tasks"]
|
|
}
|
|
assert statuses.get(t["id"]) != "running"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comments + Links
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_add_comment(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
|
json={"body": "how's progress?", "author": "teknium"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{t['id']}")
|
|
comments = r.json()["comments"]
|
|
assert len(comments) == 1
|
|
assert comments[0]["body"] == "how's progress?"
|
|
assert comments[0]["author"] == "teknium"
|
|
|
|
|
|
def test_add_comment_empty_rejected(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
|
json={"body": " "},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_add_link_and_delete_link(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
|
|
r = client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{b['id']}")
|
|
assert a["id"] in r.json()["links"]["parents"]
|
|
|
|
r = client.delete(
|
|
"/api/plugins/kanban/links",
|
|
params={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
|
|
|
|
def test_add_link_cycle_rejected(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": b["id"], "child_id": a["id"]},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch nudge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_dry_run(client):
|
|
client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "work", "assignee": "researcher"},
|
|
)
|
|
r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
# DispatchResult is serialized as a dataclass dict.
|
|
assert isinstance(body, dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Triage column (new v1 status)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_triage_lands_in_triage_column(client):
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "rough idea, spec me", "triage": True},
|
|
)
|
|
assert r.status_code == 200
|
|
task = r.json()["task"]
|
|
assert task["status"] == "triage"
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
assert len(triage["tasks"]) == 1
|
|
assert triage["tasks"][0]["title"] == "rough idea, spec me"
|
|
|
|
|
|
def test_triage_task_not_promoted_to_ready(client):
|
|
"""Triage tasks must stay in triage even when they have no parents."""
|
|
client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "must stay put", "triage": True},
|
|
)
|
|
# Run the dispatcher — it should NOT promote the triage task.
|
|
client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4")
|
|
r = client.get("/api/plugins/kanban/board")
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
ready = next(c for c in r.json()["columns"] if c["name"] == "ready")
|
|
assert len(triage["tasks"]) == 1
|
|
assert len(ready["tasks"]) == 0
|
|
|
|
|
|
def test_patch_status_triage_works(client):
|
|
"""A user (or specifier) can push a task back into triage, and out of it."""
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "x"},
|
|
).json()["task"]
|
|
# Normal creation is 'ready'; push to triage.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "triage"
|
|
|
|
# Now promote to todo.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "todo"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress rollup (done children / total children)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_progress_rollup(client):
|
|
parent = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
|
).json()["task"]
|
|
child_a = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "a", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
child_b = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "b", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
# Children start as "todo" because the parent isn't done yet. Set the
|
|
# parent to done so children auto-promote to ready via recompute_ready.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{parent['id']}",
|
|
json={"status": "done"},
|
|
)
|
|
assert r.status_code == 200
|
|
# Verify children are now ready.
|
|
for cid in (child_a["id"], child_b["id"]):
|
|
t = client.get(f"/api/plugins/kanban/tasks/{cid}").json()["task"]
|
|
assert t["status"] == "ready", f"{cid} should be ready after parent done"
|
|
|
|
# 0/2 done.
|
|
r = client.get("/api/plugins/kanban/board")
|
|
parent_row = next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == parent["id"]
|
|
)
|
|
assert parent_row["progress"] == {"done": 0, "total": 2}
|
|
|
|
# Complete one child. 1/2.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{child_a['id']}",
|
|
json={"status": "done"},
|
|
)
|
|
assert r.status_code == 200
|
|
r = client.get("/api/plugins/kanban/board")
|
|
parent_row = next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == parent["id"]
|
|
)
|
|
assert parent_row["progress"] == {"done": 1, "total": 2}
|
|
|
|
# Childless tasks report progress=None, not {0/0}.
|
|
assert next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == child_b["id"]
|
|
)["progress"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-init on first board read
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
|
"""If kanban.db doesn't exist yet, GET /board must create it, not 500."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False)
|
|
monkeypatch.delenv("HERMES_KANBAN_DB", raising=False)
|
|
monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
# Deliberately DO NOT call kb.init_db().
|
|
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
c = TestClient(app)
|
|
r = c.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket auth (query-param token)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
|
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
|
wrong ?token= query param must be rejected with policy-violation."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
|
|
# Stub web_server so _check_ws_token has a token to compare against.
|
|
import hermes_cli
|
|
import types
|
|
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
|
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
|
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
c = TestClient(app)
|
|
|
|
# No token → policy violation close.
|
|
from starlette.websockets import WebSocketDisconnect
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
with c.websocket_connect("/api/plugins/kanban/events"):
|
|
pass
|
|
assert exc.value.code == 1008
|
|
|
|
# Wrong token → policy violation close.
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
with c.websocket_connect("/api/plugins/kanban/events?token=nope"):
|
|
pass
|
|
assert exc.value.code == 1008
|
|
|
|
# Correct token → accepted (connect then close cleanly from our side).
|
|
with c.websocket_connect(
|
|
"/api/plugins/kanban/events?token=secret-xyz"
|
|
) as ws:
|
|
assert ws is not None # handshake succeeded
|
|
|
|
|
|
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
|
|
"""The event stream must honor ``board=default`` even when the global
|
|
current-board pointer targets a different board.
|
|
|
|
This is the live-update half of the dashboard regression: after the UI
|
|
selects Default, the websocket must not subscribe to the CLI's current
|
|
non-default board.
|
|
"""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
|
|
default_conn = kb.connect()
|
|
try:
|
|
default_task = kb.create_task(default_conn, title="default-live")
|
|
finally:
|
|
default_conn.close()
|
|
|
|
kb.create_board("other")
|
|
other_conn = kb.connect(board="other")
|
|
try:
|
|
other_task = kb.create_task(other_conn, title="other-live")
|
|
finally:
|
|
other_conn.close()
|
|
|
|
kb.set_current_board("other")
|
|
|
|
import hermes_cli
|
|
import types
|
|
|
|
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
|
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
|
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
c = TestClient(app)
|
|
|
|
with c.websocket_connect(
|
|
"/api/plugins/kanban/events?token=secret-xyz&board=default&since=0"
|
|
) as ws:
|
|
payload = ws.receive_json()
|
|
|
|
task_ids = {event["task_id"] for event in payload["events"]}
|
|
assert default_task in task_ids
|
|
assert other_task not in task_ids
|
|
|
|
|
|
def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
|
"""``asyncio.CancelledError`` while sleeping in the poll loop is the
|
|
normal uvicorn-shutdown path (``BaseException``, so the bare
|
|
``except Exception:`` does NOT catch it). Without the explicit
|
|
clause the cancellation surfaces as an application traceback.
|
|
|
|
Regression test for #20790 (fix in #20938). Drives the coroutine
|
|
directly (rather than through FastAPI TestClient) so we can observe
|
|
the cancellation outcome deterministically.
|
|
"""
|
|
import asyncio
|
|
import types
|
|
import sys as _sys
|
|
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
|
|
# Short-circuit the token check — this test is about the cancellation
|
|
# path, not auth.
|
|
import plugins.kanban.dashboard.plugin_api as pa
|
|
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
|
|
|
|
class _FakeWS:
|
|
def __init__(self):
|
|
self.query_params = {"token": "x", "since": "0"}
|
|
self.accepted = False
|
|
self.closed = False
|
|
|
|
async def accept(self):
|
|
self.accepted = True
|
|
|
|
async def send_json(self, data):
|
|
pass
|
|
|
|
async def close(self, code=None):
|
|
self.closed = True
|
|
|
|
async def _run():
|
|
ws = _FakeWS()
|
|
task = asyncio.create_task(pa.stream_events(ws))
|
|
# Give the handler a tick to accept + start polling.
|
|
await asyncio.sleep(0.05)
|
|
assert ws.accepted is True
|
|
task.cancel()
|
|
# stream_events should swallow CancelledError and return cleanly.
|
|
# If it doesn't, this await re-raises the CancelledError.
|
|
result = await task
|
|
return result, ws
|
|
|
|
result, ws = asyncio.run(_run())
|
|
assert result is None, (
|
|
f"stream_events should return cleanly after cancellation, got {result!r}"
|
|
)
|
|
# The bug symptom was a traceback; we don't assert on stderr because
|
|
# capturing asyncio's internal "exception was never retrieved" logging
|
|
# is flaky. The assertion that matters is: no CancelledError escaped.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bulk actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bulk_status_ready(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
|
|
# Parent-less tasks land in "ready" already; push them to blocked first.
|
|
for tid in (a["id"], b["id"], c2["id"]):
|
|
client.patch(f"/api/plugins/kanban/tasks/{tid}",
|
|
json={"status": "blocked", "block_reason": "wait"})
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [a["id"], b["id"], c2["id"]], "status": "ready"})
|
|
assert r.status_code == 200
|
|
results = r.json()["results"]
|
|
assert all(r["ok"] for r in results)
|
|
# All three are now ready.
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
ready = next(col for col in board["columns"] if col["name"] == "ready")
|
|
ids = {t["id"] for t in ready["tasks"]}
|
|
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
|
|
|
|
|
|
def test_bulk_status_done_forwards_completion_summary(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks/bulk",
|
|
json={
|
|
"ids": [a["id"], b["id"]],
|
|
"status": "done",
|
|
"result": "DECIDED: ship it",
|
|
"summary": "DECIDED: ship it",
|
|
"metadata": {"source": "dashboard"},
|
|
},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert all(r["ok"] for r in r.json()["results"])
|
|
conn = kb.connect()
|
|
try:
|
|
for tid in (a["id"], b["id"]):
|
|
task = kb.get_task(conn, tid)
|
|
run = kb.latest_run(conn, tid)
|
|
assert task.status == "done"
|
|
assert task.result == "DECIDED: ship it"
|
|
assert run.summary == "DECIDED: ship it"
|
|
assert run.metadata == {"source": "dashboard"}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_bulk_status_running_rejected(client):
|
|
"""Bulk updates must match single-task PATCH: direct 'running' is invalid."""
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [t["id"]], "status": "running"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
results = r.json()["results"]
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == t["id"]
|
|
assert results[0]["ok"] is False
|
|
assert "running" in results[0]["error"]
|
|
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
statuses = {
|
|
tt["id"]: col["name"]
|
|
for col in board["columns"]
|
|
for tt in col["tasks"]
|
|
}
|
|
assert statuses.get(t["id"]) != "running"
|
|
|
|
|
|
def test_dashboard_done_actions_prompt_for_completion_summary():
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = (
|
|
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
).read_text()
|
|
|
|
assert "withCompletionSummary" in bundle
|
|
assert "Completion summary" in bundle
|
|
assert "result: summary" in bundle
|
|
assert "body: JSON.stringify(patch)" in bundle
|
|
assert "body: JSON.stringify(finalPatch)" in bundle
|
|
|
|
|
|
def test_dashboard_surfaces_ready_blocked_error_inline():
|
|
"""Regression for #26744: failed status transitions must be surfaced
|
|
inline, not swallowed. The drag/drop banner and the drawer's action
|
|
row each render the parsed API ``detail`` so operators see *why*
|
|
their click did nothing.
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = (
|
|
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
).read_text()
|
|
|
|
# Helper that strips ``"409: {\"detail\":\"…\"}"`` down to the
|
|
# human-readable message before it lands in any banner.
|
|
assert "function parseApiErrorMessage(err)" in bundle
|
|
assert "parsed.detail" in bundle
|
|
|
|
# Drag/drop banner now uses the parsed message instead of raw
|
|
# ``err.message`` so it no longer leaks HTTP plumbing.
|
|
assert "setError(tx(t, \"moveFailed\", \"Move failed: \") + parseApiErrorMessage(err))" in bundle
|
|
|
|
# Drawer action row has its own visible error surface and clears it
|
|
# on success/refresh so stale failures don't follow the operator
|
|
# around.
|
|
assert "const [patchErr, setPatchErr] = useState(null);" in bundle
|
|
assert "setPatchErr(parseApiErrorMessage(e))" in bundle
|
|
assert "setPatchErr(null)" in bundle
|
|
|
|
|
|
def test_dashboard_dependency_selects_use_value_change_handler():
|
|
"""Regression for the dependency selects in the task drawer: the
|
|
add-parent / add-child dropdowns must wire through the shared
|
|
selectChangeHandler helper so their value actually lands on the
|
|
underlying React state. Salvaged from #20019 @LeonSGP43.
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
bundle = (
|
|
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
|
).read_text()
|
|
|
|
parent_select = (
|
|
'value: newParent,\n'
|
|
' className: "h-7 text-xs flex-1",\n'
|
|
' }, selectChangeHandler(setNewParent))'
|
|
)
|
|
child_select = (
|
|
'value: newChild,\n'
|
|
' className: "h-7 text-xs flex-1",\n'
|
|
' }, selectChangeHandler(setNewChild))'
|
|
)
|
|
|
|
assert parent_select in bundle
|
|
assert child_select in bundle
|
|
|
|
|
|
def test_bulk_archive(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [a["id"], b["id"]], "archive": True})
|
|
assert r.status_code == 200
|
|
assert all(r["ok"] for r in r.json()["results"])
|
|
# Default board (archived hidden) — both gone.
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
ids = {t["id"] for col in board["columns"] for t in col["tasks"]}
|
|
assert a["id"] not in ids
|
|
assert b["id"] not in ids
|
|
|
|
|
|
def test_bulk_reassign(client):
|
|
a = client.post("/api/plugins/kanban/tasks",
|
|
json={"title": "a", "assignee": "old"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks",
|
|
json={"title": "b", "assignee": "old"}).json()["task"]
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [a["id"], b["id"]], "assignee": "new"})
|
|
assert r.status_code == 200
|
|
for tid in (a["id"], b["id"]):
|
|
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
|
|
assert t["assignee"] == "new"
|
|
|
|
|
|
def test_bulk_unassign_via_empty_string(client):
|
|
a = client.post("/api/plugins/kanban/tasks",
|
|
json={"title": "a", "assignee": "x"}).json()["task"]
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [a["id"]], "assignee": ""})
|
|
assert r.status_code == 200
|
|
t = client.get(f"/api/plugins/kanban/tasks/{a['id']}").json()["task"]
|
|
assert t["assignee"] is None
|
|
|
|
|
|
def test_bulk_partial_failure_doesnt_abort_siblings(client):
|
|
"""One bad id in the middle of a batch must not prevent others from
|
|
applying."""
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
json={"ids": [a["id"], "bogus-id", c2["id"]], "priority": 7})
|
|
assert r.status_code == 200
|
|
results = r.json()["results"]
|
|
assert len(results) == 3
|
|
ok_ids = {r["id"] for r in results if r["ok"]}
|
|
assert a["id"] in ok_ids
|
|
assert c2["id"] in ok_ids
|
|
assert any(not r["ok"] and r["id"] == "bogus-id" for r in results)
|
|
# Good siblings actually got the priority bump.
|
|
for tid in (a["id"], c2["id"]):
|
|
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
|
|
assert t["priority"] == 7
|
|
|
|
|
|
def test_bulk_empty_ids_400(client):
|
|
r = client.post("/api/plugins/kanban/tasks/bulk", json={"ids": []})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /config endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_config_returns_defaults_when_section_missing(client):
|
|
r = client.get("/api/plugins/kanban/config")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
# Defaults when dashboard.kanban is missing.
|
|
assert data["default_tenant"] == ""
|
|
assert data["lane_by_profile"] is True
|
|
assert data["include_archived_by_default"] is False
|
|
assert data["render_markdown"] is True
|
|
|
|
|
|
def test_config_reads_dashboard_kanban_section(tmp_path, monkeypatch, client):
|
|
home = Path(os.environ["HERMES_HOME"])
|
|
(home / "config.yaml").write_text(
|
|
"dashboard:\n"
|
|
" kanban:\n"
|
|
" default_tenant: acme\n"
|
|
" lane_by_profile: false\n"
|
|
" include_archived_by_default: true\n"
|
|
" render_markdown: false\n"
|
|
)
|
|
r = client.get("/api/plugins/kanban/config")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["default_tenant"] == "acme"
|
|
assert data["lane_by_profile"] is False
|
|
assert data["include_archived_by_default"] is True
|
|
assert data["render_markdown"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runs surfacing (vulcan-artivus RFC feedback)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_task_detail_includes_runs(client):
|
|
"""GET /tasks/:id carries a runs[] array with the attempt history."""
|
|
r = client.post("/api/plugins/kanban/tasks",
|
|
json={"title": "port x", "assignee": "worker"}).json()
|
|
tid = r["task"]["id"]
|
|
|
|
# Drive status running to force a run creation: PATCH to running
|
|
# doesn't call claim_task (the PATCH path uses _set_status_direct),
|
|
# so use the bulk/claim indirection via the kernel.
|
|
import hermes_cli.kanban_db as _kb
|
|
conn = _kb.connect()
|
|
try:
|
|
_kb.claim_task(conn, tid)
|
|
_kb.complete_task(
|
|
conn, tid,
|
|
result="done",
|
|
summary="tested on rate limiter",
|
|
metadata={"changed_files": ["limiter.py"]},
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
d = client.get(f"/api/plugins/kanban/tasks/{tid}").json()
|
|
assert "runs" in d
|
|
assert len(d["runs"]) == 1
|
|
run = d["runs"][0]
|
|
assert run["outcome"] == "completed"
|
|
assert run["profile"] == "worker"
|
|
assert run["summary"] == "tested on rate limiter"
|
|
assert run["metadata"] == {"changed_files": ["limiter.py"]}
|
|
assert run["ended_at"] is not None
|
|
|
|
|
|
def test_task_detail_runs_empty_before_claim(client):
|
|
"""A task that's never been claimed has an empty runs[] list, not
|
|
a missing key."""
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "fresh"}).json()
|
|
d = client.get(f"/api/plugins/kanban/tasks/{r['task']['id']}").json()
|
|
assert d["runs"] == []
|
|
|
|
|
|
def test_patch_status_done_with_summary_and_metadata(client):
|
|
"""PATCH /tasks/:id with status=done + summary + metadata must
|
|
reach complete_task, so the dashboard has CLI parity."""
|
|
# Create + claim.
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "x", "assignee": "worker"})
|
|
tid = r.json()["task"]["id"]
|
|
from hermes_cli import kanban_db as kb
|
|
conn = kb.connect()
|
|
try:
|
|
kb.claim_task(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
json={
|
|
"status": "done",
|
|
"summary": "shipped the thing",
|
|
"metadata": {"changed_files": ["a.py", "b.py"], "tests_run": 7},
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# The run must have the summary + metadata attached.
|
|
conn = kb.connect()
|
|
try:
|
|
run = kb.latest_run(conn, tid)
|
|
assert run.outcome == "completed"
|
|
assert run.summary == "shipped the thing"
|
|
assert run.metadata == {"changed_files": ["a.py", "b.py"], "tests_run": 7}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_patch_status_done_without_summary_still_works(client):
|
|
"""Back-compat: PATCH without the new fields still completes."""
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "y", "assignee": "worker"})
|
|
tid = r.json()["task"]["id"]
|
|
from hermes_cli import kanban_db as kb
|
|
conn = kb.connect()
|
|
try:
|
|
kb.claim_task(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
json={"status": "done", "result": "legacy shape"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
conn = kb.connect()
|
|
try:
|
|
run = kb.latest_run(conn, tid)
|
|
assert run.outcome == "completed"
|
|
assert run.summary == "legacy shape" # falls back to result
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_patch_status_archive_closes_running_run(client):
|
|
"""PATCH to archived while running must close the in-flight run."""
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "z", "assignee": "worker"})
|
|
tid = r.json()["task"]["id"]
|
|
from hermes_cli import kanban_db as kb
|
|
conn = kb.connect()
|
|
try:
|
|
kb.claim_task(conn, tid)
|
|
open_run = kb.latest_run(conn, tid)
|
|
assert open_run.ended_at is None
|
|
finally:
|
|
conn.close()
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
json={"status": "archived"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
conn = kb.connect()
|
|
try:
|
|
task = kb.get_task(conn, tid)
|
|
assert task.status == "archived"
|
|
assert task.current_run_id is None
|
|
assert kb.latest_run(conn, tid).outcome == "reclaimed"
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_event_dict_includes_run_id(client):
|
|
"""GET /tasks/:id returns events with run_id populated."""
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "e", "assignee": "worker"})
|
|
tid = r.json()["task"]["id"]
|
|
from hermes_cli import kanban_db as kb
|
|
conn = kb.connect()
|
|
try:
|
|
kb.claim_task(conn, tid)
|
|
run_id = kb.latest_run(conn, tid).id
|
|
kb.complete_task(conn, tid, summary="wss")
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{tid}")
|
|
assert r.status_code == 200
|
|
events = r.json()["events"]
|
|
# Every event in the response must have a run_id key (None or int).
|
|
for e in events:
|
|
assert "run_id" in e, f"missing run_id in event: {e}"
|
|
# completed event must have the actual run_id.
|
|
comp = [e for e in events if e["kind"] == "completed"]
|
|
assert comp[0]["run_id"] == run_id
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-task force-loaded skills via REST
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_task_with_skills_roundtrips(client):
|
|
"""POST /tasks accepts `skills: [...]`, GET /tasks/:id returns it."""
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={
|
|
"title": "translate docs",
|
|
"assignee": "linguist",
|
|
"skills": ["translation", "github-code-review"],
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
task = r.json()["task"]
|
|
assert task["skills"] == ["translation", "github-code-review"]
|
|
|
|
# Fetch via GET /tasks/:id as the drawer does.
|
|
got = client.get(f"/api/plugins/kanban/tasks/{task['id']}").json()
|
|
assert got["task"]["skills"] == ["translation", "github-code-review"]
|
|
|
|
|
|
def test_create_task_without_skills_defaults_to_empty_list(client):
|
|
"""_task_dict serializes Task.skills=None as [] so the drawer can
|
|
always .length check without guarding against null."""
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "no skills", "assignee": "x"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
task = r.json()["task"]
|
|
# Task.skills is None in-memory; _task_dict serializes via
|
|
# dataclasses.asdict which keeps it None. The drawer's
|
|
# `t.skills && t.skills.length > 0` guard handles both null and [].
|
|
assert task.get("skills") in (None, [])
|
|
|
|
|
|
def test_create_task_with_toolset_name_in_skills_is_rejected(client):
|
|
"""POST /tasks fails fast when callers confuse toolsets with skills."""
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={
|
|
"title": "bad skills payload",
|
|
"assignee": "linguist",
|
|
"skills": ["web"],
|
|
},
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert "toolset name" in r.json()["detail"]
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher-presence warning in POST /tasks response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_task_includes_warning_when_no_dispatcher(client, monkeypatch):
|
|
"""ready+assigned task + no gateway -> response has `warning` field
|
|
so the dashboard UI can surface a banner."""
|
|
# Force the dispatcher probe to report "not running".
|
|
monkeypatch.setattr(
|
|
"hermes_cli.kanban._check_dispatcher_presence",
|
|
lambda: (False, "No gateway is running — start `hermes gateway start`."),
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "warn-me", "assignee": "worker"},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data.get("warning")
|
|
assert "gateway" in data["warning"].lower()
|
|
|
|
|
|
def test_create_task_no_warning_when_dispatcher_up(client, monkeypatch):
|
|
"""Dispatcher running -> no `warning` field in the response."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.kanban._check_dispatcher_presence",
|
|
lambda: (True, ""),
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "silent", "assignee": "worker"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "warning" not in r.json() or not r.json()["warning"]
|
|
|
|
|
|
def test_create_task_no_warning_on_triage(client, monkeypatch):
|
|
"""Triage tasks never get the warning (they can't be dispatched
|
|
anyway until promoted)."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.kanban._check_dispatcher_presence",
|
|
lambda: (False, "oh no"),
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "triage-task", "assignee": "worker", "triage": True},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "warning" not in r.json() or not r.json()["warning"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _task_dict — outer try/except fallback when task_age raises
|
|
#
|
|
# Background: kanban_db.task_age was hardened in 061a1830 to return None for
|
|
# corrupt timestamp values via _safe_int. The companion fix added a belt-and-
|
|
# suspenders try/except in plugin_api._task_dict so that *any future* exception
|
|
# from task_age (not just ValueError on '%s') still yields a usable dict
|
|
# instead of 500'ing GET /board for the entire org.
|
|
#
|
|
# kanban_db._safe_int / task_age corruption paths are covered in
|
|
# tests/hermes_cli/test_kanban_db.py. The OUTER fallback here is not, which
|
|
# means a refactor that drops the try/except would not be caught by CI. The
|
|
# tests below pin that contract.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_FALLBACK_AGE = {
|
|
"created_age_seconds": None,
|
|
"started_age_seconds": None,
|
|
"time_to_complete_seconds": None,
|
|
}
|
|
|
|
|
|
def test_board_endpoint_survives_task_age_exception(client, monkeypatch):
|
|
"""If task_age raises for any reason, GET /board must NOT 500.
|
|
|
|
Pre-fix behavior (without the try/except in _task_dict): a single corrupt
|
|
row turned the entire board response into a 500. The fallback dict lets
|
|
the dashboard render every other card normally.
|
|
"""
|
|
create = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "doomed", "assignee": "alice"},
|
|
)
|
|
assert create.status_code == 200, create.text
|
|
|
|
# Force task_age to raise an exception type _safe_int does NOT handle —
|
|
# simulates a future regression where someone re-introduces an unguarded
|
|
# operation in task_age. ValueError on '%s' would be absorbed by _safe_int
|
|
# and never reach the outer try/except, so it would not exercise the
|
|
# contract this test pins.
|
|
def _boom(_task):
|
|
raise RuntimeError("simulated future task_age bug")
|
|
monkeypatch.setattr("hermes_cli.kanban_db.task_age", _boom)
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200, r.text
|
|
|
|
payload = r.json()
|
|
# /board returns columns as a list of {name, tasks} — not a dict — so
|
|
# flatten across all columns to find our seeded task.
|
|
tasks = [t for col in payload["columns"] for t in col["tasks"]]
|
|
assert len(tasks) == 1, f"expected exactly the seeded task, got {tasks!r}"
|
|
# Strict equality: the literal fallback dict from plugin_api._task_dict
|
|
# is the published contract the dashboard UI relies on. Key renames or
|
|
# silent additions should fail this test on purpose.
|
|
assert tasks[0]["age"] == _FALLBACK_AGE
|
|
|
|
|
|
def test_single_task_endpoint_survives_task_age_exception(client, monkeypatch):
|
|
"""GET /tasks/:id also calls _task_dict — same fallback should kick in.
|
|
|
|
This is the "drawer view" path: the user clicks one card and we serialize
|
|
just that task. A corrupt timestamp on a single task should not block the
|
|
user from opening its drawer.
|
|
"""
|
|
create = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "drawer-target", "assignee": "bob"},
|
|
)
|
|
task_id = create.json()["task"]["id"]
|
|
|
|
def _boom(_task):
|
|
raise RuntimeError("simulated future task_age bug")
|
|
monkeypatch.setattr("hermes_cli.kanban_db.task_age", _boom)
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{task_id}")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["task"]["age"] == _FALLBACK_AGE
|
|
|
|
|
|
def test_create_task_probe_error_does_not_break_create(client, monkeypatch):
|
|
"""Probe failure must never break task creation."""
|
|
def _raise():
|
|
raise RuntimeError("probe crashed")
|
|
monkeypatch.setattr(
|
|
"hermes_cli.kanban._check_dispatcher_presence", _raise,
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "resilient", "assignee": "worker"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["title"] == "resilient"
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Home-channel subscription endpoints (#19534 follow-up: GUI opt-in)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Dashboard surface for per-task, per-platform notification toggles. The
|
|
# backend endpoints read the live GatewayConfig, so tests set env vars
|
|
# (BOT_TOKEN + HOME_CHANNEL) to simulate a user who has run /sethome on
|
|
# telegram and discord.
|
|
|
|
|
|
@pytest.fixture
|
|
def with_home_channels(monkeypatch):
|
|
"""Simulate a user with home channels set on telegram and discord."""
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc:fake")
|
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "1234567")
|
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_THREAD_ID", "42")
|
|
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_NAME", "Main TG")
|
|
monkeypatch.setenv("DISCORD_BOT_TOKEN", "disc_fake")
|
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "9999999")
|
|
monkeypatch.setenv("DISCORD_HOME_CHANNEL_NAME", "Main Discord")
|
|
# Slack has a token but NO home — should be excluded from the list.
|
|
monkeypatch.setenv("SLACK_BOT_TOKEN", "slack_fake")
|
|
|
|
|
|
def test_home_channels_lists_only_platforms_with_home(client, with_home_channels):
|
|
"""GET /home-channels returns entries only for platforms where the
|
|
user has set a home; untoggled-subscribed bool is false by default."""
|
|
r = client.get("/api/plugins/kanban/home-channels")
|
|
assert r.status_code == 200
|
|
platforms = {h["platform"] for h in r.json()["home_channels"]}
|
|
assert platforms == {"telegram", "discord"}, (
|
|
f"slack has a token but no home — must not appear. got {platforms}"
|
|
)
|
|
for h in r.json()["home_channels"]:
|
|
assert h["subscribed"] is False
|
|
|
|
|
|
def test_home_channels_no_task_id_all_unsubscribed(client, with_home_channels):
|
|
"""Without task_id, every entry's subscribed=false (UI "no task" state)."""
|
|
r = client.get("/api/plugins/kanban/home-channels")
|
|
assert r.status_code == 200
|
|
assert all(not h["subscribed"] for h in r.json()["home_channels"])
|
|
|
|
|
|
def test_home_subscribe_creates_notify_sub_row(client, with_home_channels):
|
|
"""POST .../home-subscribe/telegram writes a kanban_notify_subs row
|
|
keyed to the telegram home's (chat_id, thread_id)."""
|
|
from hermes_cli import kanban_db as kb
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
|
|
r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
subs = kb.list_notify_subs(conn, t["id"])
|
|
finally:
|
|
conn.close()
|
|
assert len(subs) == 1
|
|
assert subs[0]["platform"] == "telegram"
|
|
assert subs[0]["chat_id"] == "1234567"
|
|
assert subs[0]["thread_id"] == "42"
|
|
assert subs[0]["notifier_profile"] == "default"
|
|
|
|
|
|
def test_home_subscribe_flips_subscribed_flag_in_subsequent_get(client, with_home_channels):
|
|
"""After subscribe, the GET endpoint reports subscribed=true for that
|
|
platform and false for the others."""
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
|
|
r = client.get(f"/api/plugins/kanban/home-channels?task_id={t['id']}")
|
|
flags = {h["platform"]: h["subscribed"] for h in r.json()["home_channels"]}
|
|
assert flags == {"telegram": True, "discord": False}
|
|
|
|
|
|
def test_home_subscribe_is_idempotent(client, with_home_channels):
|
|
"""Re-subscribing keeps a single row at the DB layer."""
|
|
from hermes_cli import kanban_db as kb
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
conn = kb.connect()
|
|
try:
|
|
assert len(kb.list_notify_subs(conn, t["id"])) == 1
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_home_subscribe_backfills_owner_on_legacy_row(client, with_home_channels):
|
|
"""Re-subscribing should backfill notifier ownership on ownerless rows."""
|
|
from hermes_cli import kanban_db as kb
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
kb.add_notify_sub(
|
|
conn,
|
|
task_id=t["id"],
|
|
platform="telegram",
|
|
chat_id="1234567",
|
|
thread_id="42",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
assert r.status_code == 200
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
subs = kb.list_notify_subs(conn, t["id"])
|
|
finally:
|
|
conn.close()
|
|
|
|
assert len(subs) == 1
|
|
assert subs[0]["notifier_profile"] == "default"
|
|
|
|
|
|
def test_home_subscribe_unknown_platform_returns_404(client, with_home_channels):
|
|
"""Platforms without a home configured (slack in the fixture) return 404."""
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/slack")
|
|
assert r.status_code == 404
|
|
assert "slack" in r.json()["detail"]
|
|
|
|
|
|
def test_home_subscribe_unknown_task_returns_404(client, with_home_channels):
|
|
r = client.post("/api/plugins/kanban/tasks/t_nonexistent/home-subscribe/telegram")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_home_unsubscribe_removes_notify_sub_row(client, with_home_channels):
|
|
"""DELETE .../home-subscribe/telegram removes the matching row."""
|
|
from hermes_cli import kanban_db as kb
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
assert r.status_code == 200
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
assert kb.list_notify_subs(conn, t["id"]) == []
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_home_subscribe_multiple_platforms_independent(client, with_home_channels):
|
|
"""Subscribing on telegram does not affect discord and vice versa."""
|
|
from hermes_cli import kanban_db as kb
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/discord")
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])}
|
|
finally:
|
|
conn.close()
|
|
assert set(subs) == {"telegram", "discord"}
|
|
|
|
# Unsubscribe telegram only.
|
|
client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
|
conn = kb.connect()
|
|
try:
|
|
subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])}
|
|
finally:
|
|
conn.close()
|
|
assert set(subs) == {"discord"}
|
|
|
|
|
|
def test_home_channels_empty_when_no_homes_configured(client, monkeypatch):
|
|
"""Zero platforms with a home -> empty list (UI hides the section)."""
|
|
# No BOT_TOKEN env vars set → load_gateway_config().platforms is empty.
|
|
for var in [
|
|
"TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL",
|
|
"DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL",
|
|
"SLACK_BOT_TOKEN",
|
|
]:
|
|
monkeypatch.delenv(var, raising=False)
|
|
r = client.get("/api/plugins/kanban/home-channels")
|
|
assert r.status_code == 200
|
|
assert r.json()["home_channels"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recovery endpoints (reclaim + reassign) and warnings field
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_board_surfaces_warnings_field_for_hallucinated_completions(client):
|
|
"""Tasks with a pending completion_blocked_hallucination event surface
|
|
a ``warnings`` object on the /board payload so the UI can badge
|
|
them without fetching per-task events. The warnings summary is
|
|
keyed by diagnostic kind (``hallucinated_cards``) rather than the
|
|
raw event kind — see hermes_cli.kanban_diagnostics for the rule
|
|
that produces it.
|
|
"""
|
|
conn = kb.connect()
|
|
try:
|
|
parent = kb.create_task(conn, title="parent", assignee="alice")
|
|
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
|
|
|
import pytest as _pytest
|
|
with _pytest.raises(kb.HallucinatedCardsError):
|
|
kb.complete_task(
|
|
conn, parent,
|
|
summary="claimed phantom",
|
|
created_cards=[real, "t_deadbeefcafe"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
tasks = [t for col in data["columns"] for t in col["tasks"]]
|
|
parent_dict = next(t for t in tasks if t["title"] == "parent")
|
|
assert parent_dict.get("warnings") is not None
|
|
w = parent_dict["warnings"]
|
|
assert w["count"] >= 1
|
|
assert "hallucinated_cards" in w["kinds"]
|
|
assert w["highest_severity"] == "error"
|
|
# Full diagnostic list also on the payload for drawer rendering.
|
|
assert parent_dict.get("diagnostics") is not None
|
|
assert parent_dict["diagnostics"][0]["kind"] == "hallucinated_cards"
|
|
assert "t_deadbeefcafe" in parent_dict["diagnostics"][0]["data"]["phantom_ids"]
|
|
|
|
|
|
def test_board_warnings_cleared_after_clean_completion(client):
|
|
"""A completed or edited event after a hallucination event clears
|
|
the warning badge — we don't mark tasks permanently."""
|
|
conn = kb.connect()
|
|
try:
|
|
parent = kb.create_task(conn, title="parent", assignee="alice")
|
|
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
|
|
|
import pytest as _pytest
|
|
with _pytest.raises(kb.HallucinatedCardsError):
|
|
kb.complete_task(
|
|
conn, parent,
|
|
summary="first attempt phantom",
|
|
created_cards=[real, "t_phantom11"],
|
|
)
|
|
|
|
# Second attempt drops the bad id — succeeds.
|
|
ok = kb.complete_task(
|
|
conn, parent,
|
|
summary="retry without phantom",
|
|
created_cards=[real],
|
|
)
|
|
assert ok is True
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/board", params={"include_archived": True})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
tasks = [t for col in data["columns"] for t in col["tasks"]]
|
|
parent_dict = next(t for t in tasks if t["title"] == "parent")
|
|
# The clean completion wiped the warning.
|
|
assert parent_dict.get("warnings") is None
|
|
|
|
|
|
def test_reclaim_endpoint_releases_running_claim(client):
|
|
"""POST /tasks/<id>/reclaim drops the claim, returns ok, and emits
|
|
a manual reclaimed event."""
|
|
import secrets
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="running", assignee="x")
|
|
lock = secrets.token_hex(8)
|
|
future = int(time.time()) + 3600
|
|
conn.execute(
|
|
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
|
"worker_pid=? WHERE id=?",
|
|
(lock, future, 99999, t),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
|
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
|
(t, lock, future, 99999, int(time.time())),
|
|
)
|
|
run_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (run_id, t))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t}/reclaim",
|
|
json={"reason": "browser recovery"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["task_id"] == t
|
|
|
|
# Confirm the task is back to ready.
|
|
conn2 = kb.connect()
|
|
try:
|
|
row = conn2.execute(
|
|
"SELECT status, claim_lock FROM tasks WHERE id=?", (t,),
|
|
).fetchone()
|
|
assert row["status"] == "ready"
|
|
assert row["claim_lock"] is None
|
|
finally:
|
|
conn2.close()
|
|
|
|
|
|
def test_reclaim_endpoint_409_for_non_running_task(client):
|
|
"""Reclaiming a task that's already ready returns 409."""
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="ready", assignee="x")
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t}/reclaim",
|
|
json={},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
|
|
def test_reassign_endpoint_switches_profile(client):
|
|
"""POST /tasks/<id>/reassign changes the assignee field."""
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="task", assignee="orig")
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t}/reassign",
|
|
json={"profile": "newbie", "reclaim_first": False},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["assignee"] == "newbie"
|
|
|
|
conn2 = kb.connect()
|
|
try:
|
|
row = conn2.execute(
|
|
"SELECT assignee FROM tasks WHERE id=?", (t,),
|
|
).fetchone()
|
|
assert row["assignee"] == "newbie"
|
|
finally:
|
|
conn2.close()
|
|
|
|
|
|
def test_reassign_endpoint_409_on_running_without_reclaim(client):
|
|
"""Reassigning a running task without reclaim_first returns 409."""
|
|
import secrets
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="running", assignee="orig")
|
|
conn.execute(
|
|
"UPDATE tasks SET status='running', claim_lock=? WHERE id=?",
|
|
(secrets.token_hex(4), t),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t}/reassign",
|
|
json={"profile": "new", "reclaim_first": False},
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
|
|
def test_reassign_endpoint_with_reclaim_first_succeeds_on_running(client):
|
|
"""With reclaim_first=true, a running task is reclaimed+reassigned in
|
|
one call."""
|
|
import secrets
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="running", assignee="orig")
|
|
lock = secrets.token_hex(4)
|
|
conn.execute(
|
|
"UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, "
|
|
"worker_pid=? WHERE id=?",
|
|
(lock, int(time.time()) + 3600, 1234, t),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO task_runs (task_id, status, claim_lock, claim_expires, "
|
|
"worker_pid, started_at) VALUES (?, 'running', ?, ?, ?, ?)",
|
|
(t, lock, int(time.time()) + 3600, 1234, int(time.time())),
|
|
)
|
|
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute("UPDATE tasks SET current_run_id=? WHERE id=?", (rid, t))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t}/reassign",
|
|
json={"profile": "new", "reclaim_first": True, "reason": "switch"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["assignee"] == "new"
|
|
|
|
conn2 = kb.connect()
|
|
try:
|
|
row = conn2.execute(
|
|
"SELECT status, assignee FROM tasks WHERE id=?", (t,),
|
|
).fetchone()
|
|
assert row["status"] == "ready"
|
|
assert row["assignee"] == "new"
|
|
finally:
|
|
conn2.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diagnostics endpoint (/api/plugins/kanban/diagnostics)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_diagnostics_endpoint_empty_for_clean_board(client):
|
|
r = client.get("/api/plugins/kanban/diagnostics")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["count"] == 0
|
|
assert data["diagnostics"] == []
|
|
|
|
|
|
def test_diagnostics_endpoint_surfaces_blocked_hallucination(client):
|
|
conn = kb.connect()
|
|
try:
|
|
parent = kb.create_task(conn, title="parent", assignee="alice")
|
|
real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
|
|
import pytest as _pytest
|
|
with _pytest.raises(kb.HallucinatedCardsError):
|
|
kb.complete_task(
|
|
conn, parent, summary="phantom",
|
|
created_cards=[real, "t_ffff00001234"],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/diagnostics")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["count"] == 1
|
|
row = data["diagnostics"][0]
|
|
assert row["task_id"] == parent
|
|
assert row["diagnostics"][0]["kind"] == "hallucinated_cards"
|
|
assert row["diagnostics"][0]["severity"] == "error"
|
|
assert "t_ffff00001234" in row["diagnostics"][0]["data"]["phantom_ids"]
|
|
|
|
|
|
def test_diagnostics_endpoint_severity_filter(client):
|
|
"""Warning-severity filter excludes error-severity entries."""
|
|
conn = kb.connect()
|
|
try:
|
|
# A warning-severity diagnostic (prose phantom) on one task.
|
|
# Phantom id must be valid hex — the prose scanner regex
|
|
# requires ``t_[a-f0-9]{8,}``.
|
|
p1 = kb.create_task(conn, title="prose", assignee="a")
|
|
kb.complete_task(conn, p1, summary="mentioned t_deadbeef1234")
|
|
# An error-severity diagnostic (spawn failures) on another.
|
|
# Keep this below critical severity (failure_threshold * 2).
|
|
p2 = kb.create_task(conn, title="spawn", assignee="b")
|
|
conn.execute(
|
|
"UPDATE tasks SET consecutive_failures=2, last_failure_error='x' WHERE id=?",
|
|
(p2,),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/diagnostics?severity=warning")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["count"] == 1
|
|
assert data["diagnostics"][0]["task_id"] == p1
|
|
|
|
r = client.get("/api/plugins/kanban/diagnostics?severity=error")
|
|
data = r.json()
|
|
assert data["count"] == 1
|
|
assert data["diagnostics"][0]["task_id"] == p2
|
|
|
|
|
|
def test_board_exposes_diagnostics_list_and_summary(client):
|
|
"""/board should attach both the full diagnostics list AND the
|
|
compact warnings summary (with highest_severity) on each task
|
|
that has any diagnostic.
|
|
"""
|
|
conn = kb.connect()
|
|
try:
|
|
t = kb.create_task(conn, title="crashy", assignee="worker")
|
|
# Simulate 2 consecutive crashes -> repeated_crashes error diag
|
|
for i in range(2):
|
|
conn.execute(
|
|
"INSERT INTO task_runs (task_id, status, outcome, started_at, "
|
|
"ended_at, error) VALUES (?, 'crashed', 'crashed', ?, ?, ?)",
|
|
(t, int(time.time()) - 100, int(time.time()) - 50, "OOM"),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
data = r.json()
|
|
tasks = [x for col in data["columns"] for x in col["tasks"]]
|
|
task_dict = next(x for x in tasks if x["title"] == "crashy")
|
|
assert task_dict["warnings"] is not None
|
|
assert task_dict["warnings"]["highest_severity"] == "error"
|
|
assert task_dict["diagnostics"][0]["kind"] == "repeated_crashes"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks/:id/specify — triage specifier endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _patch_specifier_response(monkeypatch, *, content, model="test-model"):
|
|
"""Helper: install a fake auxiliary client so the specifier endpoint
|
|
can run without hitting any real provider."""
|
|
from unittest.mock import MagicMock
|
|
|
|
resp = MagicMock()
|
|
resp.choices = [MagicMock()]
|
|
resp.choices[0].message.content = content
|
|
fake_client = MagicMock()
|
|
fake_client.chat.completions.create = MagicMock(return_value=resp)
|
|
monkeypatch.setattr(
|
|
"agent.auxiliary_client.get_text_auxiliary_client",
|
|
lambda *a, **kw: (fake_client, model),
|
|
)
|
|
return fake_client
|
|
|
|
|
|
def test_specify_happy_path(client, monkeypatch):
|
|
import json as jsonlib
|
|
|
|
# Create a triage task.
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "one-liner", "triage": True},
|
|
).json()["task"]
|
|
assert t["status"] == "triage"
|
|
|
|
_patch_specifier_response(
|
|
monkeypatch,
|
|
content=jsonlib.dumps(
|
|
{"title": "Polished", "body": "**Goal**\nDo the thing."}
|
|
),
|
|
)
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/specify",
|
|
json={"author": "ui-tester"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["task_id"] == t["id"]
|
|
assert body["new_title"] == "Polished"
|
|
|
|
# Task should have moved off the triage column.
|
|
detail = client.get(f"/api/plugins/kanban/tasks/{t['id']}").json()["task"]
|
|
assert detail["status"] in {"todo", "ready"}
|
|
assert detail["title"] == "Polished"
|
|
assert "**Goal**" in (detail["body"] or "")
|
|
|
|
|
|
def test_specify_non_triage_returns_ok_false_not_http_error(client, monkeypatch):
|
|
"""The endpoint intentionally returns ``{ok: false, reason: ...}`` for
|
|
"task not in triage" rather than a 4xx — the dashboard renders the
|
|
reason inline so the user can fix it without a page reload."""
|
|
# Create a normal (ready) task — not in triage.
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
|
|
_patch_specifier_response(monkeypatch, content="unused")
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/specify",
|
|
json={},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert "not in triage" in body["reason"]
|
|
|
|
|
|
def test_specify_no_aux_client_surfaces_reason(client, monkeypatch):
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "rough", "triage": True},
|
|
).json()["task"]
|
|
|
|
# Simulate "no auxiliary client configured".
|
|
monkeypatch.setattr(
|
|
"agent.auxiliary_client.get_text_auxiliary_client",
|
|
lambda *a, **kw: (None, ""),
|
|
)
|
|
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/specify",
|
|
json={},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert "auxiliary client" in body["reason"]
|
|
|
|
# Task must stay in triage — nothing was touched.
|
|
detail = client.get(f"/api/plugins/kanban/tasks/{t['id']}").json()["task"]
|
|
assert detail["status"] == "triage"
|
|
|
|
|
|
def test_board_endpoint_accepts_explicit_board_default_param(client):
|
|
"""GET /board?board=default must not fall through to env/current-file resolution.
|
|
|
|
The dashboard always sends ``?board=<slug>`` (including ``board=default``)
|
|
so that the server-side ``current`` file can never override the dashboard's
|
|
selected board. This test asserts the endpoint accepts the parameter and
|
|
returns the default board without falling back to environment variable or
|
|
current-file resolution.
|
|
Regression: #21819.
|
|
"""
|
|
# Create a task on the default board.
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "on-default-board"},
|
|
).json()["task"]
|
|
assert t["status"] == "ready"
|
|
|
|
# Request with explicit board=default — must succeed and include the task.
|
|
r = client.get("/api/plugins/kanban/board?board=default")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
ready = next((c for c in data["columns"] if c["name"] == "ready"), None)
|
|
assert ready is not None, "no 'ready' column in default board response"
|
|
task_ids = [task["id"] for task in ready["tasks"]]
|
|
assert t["id"] in task_ids, (
|
|
f"task {t['id']} not found in ready column of default board "
|
|
f"(got tasks: {task_ids}). The board=default param was likely ignored."
|
|
)
|
|
|
|
|
|
def test_dashboard_requests_default_board_explicitly():
|
|
"""Dashboard REST calls must include board=default instead of relying on server current board."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
|
|
assert "SDK.fetchJSON(withBoard(`${API}/config`, board))" in dist
|
|
assert "SDK.fetchJSON(withBoard(`${API}/boards`, board))" in dist
|
|
assert "}, [loadBoardList, switchBoard, board]);" in dist
|
|
|
|
|
|
def test_dashboard_search_includes_body_and_result():
|
|
"""Client-side search must match body, result, latest_summary, and summary
|
|
so full card contents are findable."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
|
|
assert "t.body || \"\"" in dist
|
|
assert "t.result || \"\"" in dist
|
|
assert "t.latest_summary || \"\"" in dist
|
|
|
|
|
|
def test_dashboard_bulk_actions_include_reclaim_first():
|
|
"""Bulk action bar must expose reclaim_first checkbox and expanded status buttons."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
|
|
assert "reclaim_first: reclaimFirst" in dist
|
|
assert "hermes-kanban-bulk-reclaim-first" in dist
|
|
assert '"→ todo"' in dist
|
|
assert '"Block"' in dist
|
|
assert '"Unblock"' in dist
|
|
|
|
|
|
def test_dashboard_shift_click_range_selection_exists():
|
|
"""Shift-click must trigger range selection via toggleRange."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
|
|
assert "function toggleRange" in dist or "const toggleRange =" in dist
|
|
assert "props.toggleRange(t.id)" in dist or "props.toggleRange" in dist
|
|
assert "e.shiftKey" in dist
|
|
|
|
|
|
def test_dashboard_multi_move_bulk_exists():
|
|
"""Dragging a selected card with other selections must use /tasks/bulk."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
|
|
assert "onMoveSelected" in dist
|
|
assert "props.onMoveSelected" in dist
|
|
assert "`${API}/tasks/bulk`" in dist
|
|
|
|
|
|
def test_dashboard_failed_card_highlight_class_exists():
|
|
"""Partial bulk failures must highlight failing cards."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
js = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
|
css = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text()
|
|
|
|
assert "hermes-kanban-card--failed" in js
|
|
assert "hermes-kanban-card--failed" in css
|
|
assert "failedIds" in js
|