feat(kanban): stamp originating ACP session_id on tasks
Salvages #23208 by @awizemann. Tracks which chat session created a kanban task so clients can render a per-session board without falling back to tenant + time-window heuristics. - Schema: tasks gains nullable session_id TEXT column with index (additive migration in _migrate_add_optional_columns). - ACP: server.py exposes the originating session id via HERMES_SESSION_ID with save/restore around the agent loop. - Tool: kanban_create reads HERMES_SESSION_ID (with explicit override). - CLI: 'hermes kanban list --session <id>' filter; JSON output exposes session_id.
This commit is contained in:
@@ -156,6 +156,48 @@ def test_run_slash_tenant_filter(kanban_home):
|
||||
assert "biz-b task" in b and "biz-a task" not in b
|
||||
|
||||
|
||||
def test_run_slash_session_filter(kanban_home):
|
||||
"""`hermes kanban list --session <id>` filters by the originating
|
||||
chat session id stamped on tasks created from inside an ACP loop."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(
|
||||
conn, title="from sess-1 a", assignee="alice", session_id="sess-1"
|
||||
)
|
||||
kb.create_task(
|
||||
conn, title="from sess-1 b", assignee="alice", session_id="sess-1"
|
||||
)
|
||||
kb.create_task(
|
||||
conn, title="from sess-2", assignee="alice", session_id="sess-2"
|
||||
)
|
||||
kb.create_task(conn, title="cli only", assignee="alice")
|
||||
out_1 = kc.run_slash("list --session sess-1")
|
||||
out_2 = kc.run_slash("list --session sess-2")
|
||||
assert "from sess-1 a" in out_1
|
||||
assert "from sess-1 b" in out_1
|
||||
assert "from sess-2" not in out_1
|
||||
assert "cli only" not in out_1
|
||||
assert "from sess-2" in out_2
|
||||
assert "from sess-1 a" not in out_2
|
||||
|
||||
|
||||
def test_kanban_list_json_includes_session_id(kanban_home):
|
||||
"""JSON output exposes `session_id` so external clients (Scarf, web
|
||||
dashboards) don't need a side query to filter by chat session."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(
|
||||
conn, title="acp task", assignee="alice", session_id="acp-x"
|
||||
)
|
||||
raw = kc.run_slash("list --json")
|
||||
payload = json.loads(raw)
|
||||
assert any(
|
||||
row.get("title") == "acp task"
|
||||
and row.get("session_id") == "acp-x"
|
||||
for row in payload
|
||||
)
|
||||
|
||||
|
||||
def test_run_slash_usage_error_returns_message(kanban_home):
|
||||
# Missing required argument for create
|
||||
out = kc.run_slash("create")
|
||||
|
||||
@@ -1035,6 +1035,76 @@ def test_tenant_propagates_to_events(kanban_home):
|
||||
assert created and created[0].payload.get("tenant") == "biz-a"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Originating session id (ACP propagation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_task_stamps_session_id(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(
|
||||
conn, title="from chat", session_id="acp-sess-123"
|
||||
)
|
||||
t = kb.get_task(conn, tid)
|
||||
assert t is not None
|
||||
assert t.session_id == "acp-sess-123"
|
||||
|
||||
|
||||
def test_create_task_session_id_defaults_to_none(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(conn, title="cli-created")
|
||||
t = kb.get_task(conn, tid)
|
||||
assert t is not None
|
||||
assert t.session_id is None
|
||||
|
||||
|
||||
def test_session_id_filters_listings(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(conn, title="s1-a", session_id="sess-1")
|
||||
kb.create_task(conn, title="s1-b", session_id="sess-1")
|
||||
kb.create_task(conn, title="s2-a", session_id="sess-2")
|
||||
kb.create_task(conn, title="cli-only") # no session
|
||||
sess1 = kb.list_tasks(conn, session_id="sess-1")
|
||||
sess2 = kb.list_tasks(conn, session_id="sess-2")
|
||||
unscoped = kb.list_tasks(conn)
|
||||
assert sorted(t.title for t in sess1) == ["s1-a", "s1-b"]
|
||||
assert [t.title for t in sess2] == ["s2-a"]
|
||||
# Unscoped list still returns everything (legacy NULL rows visible).
|
||||
assert len(unscoped) == 4
|
||||
|
||||
|
||||
def test_session_id_index_exists(kanban_home):
|
||||
"""The migration creates an index on session_id for cheap per-session
|
||||
list queries on busy boards. Without it, a chat-scoped poll would
|
||||
full-scan the tasks table."""
|
||||
with kb.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' "
|
||||
"AND tbl_name='tasks'"
|
||||
).fetchall()
|
||||
names = {r["name"] for r in rows}
|
||||
assert "idx_tasks_session_id" in names
|
||||
|
||||
|
||||
def test_session_id_compose_with_tenant_filter(kanban_home):
|
||||
"""A client may want both `tenant=scarf:foo` AND `session=acp-x` —
|
||||
the filters must AND, not replace."""
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(
|
||||
conn, title="match", tenant="scarf:foo", session_id="acp-x"
|
||||
)
|
||||
kb.create_task(
|
||||
conn, title="wrong-tenant", tenant="other", session_id="acp-x"
|
||||
)
|
||||
kb.create_task(
|
||||
conn, title="wrong-session",
|
||||
tenant="scarf:foo", session_id="acp-y",
|
||||
)
|
||||
rows = kb.list_tasks(
|
||||
conn, tenant="scarf:foo", session_id="acp-x"
|
||||
)
|
||||
assert [t.title for t in rows] == ["match"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-board path resolution (issue #19348)
|
||||
#
|
||||
@@ -1557,7 +1627,8 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
|
||||
workflow_template_id TEXT,
|
||||
current_step_key TEXT,
|
||||
skills TEXT,
|
||||
max_retries INTEGER
|
||||
max_retries INTEGER,
|
||||
session_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user