diff --git a/docs/plans/thread-origin-autonomy.md b/docs/plans/thread-origin-autonomy.md new file mode 100644 index 000000000000..384699116b04 --- /dev/null +++ b/docs/plans/thread-origin-autonomy.md @@ -0,0 +1,137 @@ +# Thread-origin autonomy: close the loop back to the origin thread + wake Hollis + +## Problem (Casey, verbatim intent) + +> "Any work originating from a particular thread should always report back and +> do work in the open on that thread. And it's not just me that needs +> information. You do, too. Hollis needs to know when a subagent is done with +> its work in order to proceed." + +Today the plumbing exists but is broken end-to-end, so movement through the +system does NOT proactively reach either Casey (in his thread) or Hollis (to +proceed). Casey must run around saying "status / update / check it." That is the +opposite of the value proposition. + +## Root-cause findings (ground truth, integration head `8bbf8b8e6`) + +Three distinct defects, all real, verified in code + live data: + +### F1 — notify-sub profile mismatch (the silent-drop bug) +- The notifier delivers a subscription's ping ONLY when the sub's + `notifier_profile` equals the running gateway notifier's profile + (`kanban_watchers.py:241-242`, owner-profile gate). +- BUT subscriptions are stamped with **the profile of whoever CREATED them** + (`_active_profile_name()` = process-global `get_active_profile_name()`), not + the profile of the gateway that will DELIVER them. +- The gateway notifier runs as `default`. Subscriptions created by workers / + CLI under other profiles get stamped `salton`, `avram`, `hollis`, … and are + **silently dropped**. Live `notify-list` today: 61×salton, 5×default, + 4×hollis, 2×avram — only the 5 `default` ones can ever deliver. +- This is why Lamport's PASS ping never reached Casey, and why thread report-back + is broken in general. + +### F2 — cards do not carry their origin session +- `tasks` has a `session_id` column and `create_task(..., session_id=...)` + accepts it — but the gateway `/kanban create` path (`slash_commands.py:342-380`) + never passes it. Every thread-created card has `session_id = None`. +- Without it there is no way to wake "the session that owns this work" when the + card later transitions. + +### F3 — the transition wake targets a throwaway session, not the origin +- The 4c loopback route (`kanban-transition`) delivers to `log` and spins an + isolated `webhook:kanban-transition:` agent with NO thread + context. So even though the wake fires (proven: 202 → run), it neither posts + into the origin thread nor wakes the origin session. It dies in the log. + +## Design (locked with Casey) + +One path, no human-ping vs agent-wake split: + +**card carries origin (session + thread source) → on ANY terminal transition, +a synthetic message is delivered INTO the origin thread's session → Hollis wakes +there with full context, notices the transition, and either acts or waits for +Casey.** + +Casey's answers that fix the design: +1. Every transition wakes Hollis on the origin session; Hollis decides if there's + anything to do. (notice-everything) +2. When something is waiting for Casey (acceptance/merge gate, genuine fork), + Hollis waits — never acts past those gates. +3. All card pings route to Hollis on the session (single wire). +4. Wake = **message into the thread** (cache-safe; also what Casey sees in the + open). Reuse the existing `notify_on_complete`-style synthetic-message + injection; NEVER interrupt/rebuild a live session mid-turn. +5. Non-thread-origin work (cron/webhook/direct) → default channel + `1515879019269197885`, unless the cron/hook explicitly specifies elsewhere. +6. Hollis owns noise control (collapse/dedupe before anything reaches Casey). +7. Full dev-workflow: TDD, PR → cwest/integration, Casey merges. + +## Scope — three fixes, one PR (they are one feature) + +### Fix 1 (F1): stamp subs with the NOTIFIER's profile, not the creator's +The subscription must record the profile of the gateway that will deliver it. +- In the gateway auto-subscribe path, stamp `notifier_profile` from the running + gateway's notifier profile (`self._kanban_notifier_profile`), which is the + same value the notifier gates on — guaranteeing match by construction. +- Broader: the owner-profile gate exists to stop a multi-gateway fan-out from + double-delivering. The correct invariant is "a sub is owned by the gateway + that will deliver it." A sub created under a worker profile but intended for + the `default` gateway must be stamped `default`. Fix at the create site(s): + the gateway slash path and any orchestrator/skill subscribe helper default to + the delivering gateway's profile, not `get_active_profile_name()`. +- Reconcile existing mis-stamped live subs (data migration / one-shot re-stamp) + is an OPS step, not code — handled at deploy, out of PR scope. + +### Fix 2 (F2): stamp origin session_id on thread-created cards +- `slash_commands.py` `/kanban create`: pass `session_id` = the origin session + key (derived from `event.source`: platform+chat+thread → the session id the + gateway uses for that thread) into the create call. +- Also auto-subscribe the origin thread (already happens) — keep, but with the + corrected profile from Fix 1. + +### Fix 3 (F3): transition wake delivers INTO the origin session/thread +- The transition emitter/route already has task_id+board+kind. On wake, + resolve the card's `session_id` (+ its origin thread source from the sub) and + deliver the synthetic "card X transitioned" message INTO that session/thread, + not a throwaway webhook session. +- If the card has no origin session (cron/webhook/direct origin), fall back to + the default channel `1515879019269197885` (Casey's #5), unless the route/cron + explicitly set a target. +- Delivery uses the existing notifier chat-ping path (message into thread) — the + notifier ALREADY delivers terminal events to the subscribed thread; once Fix 1 + makes the sub deliverable and Fix 2/here ensure the thread is the origin, the + human-facing half is done. The Hollis-wake half is that same message landing + in Hollis's session so his next turn processes it. + +## Cache / alternation safety (AGENTS.md hard constraints) +- NEVER inject a synthetic user message mid-loop into a live session. The wake + is a normal inbound message on an IDLE session (exactly how `notify_on_complete` + and the existing notifier chat-ping already behave) — the next turn consumes + it, prefix cache and role alternation preserved. +- No new core model tool. No new HERMES_* env var (behavior stays in config.yaml + / existing route config). + +## TDD plan (RED → GREEN per fix) +1. **F1 test**: a sub created via the gateway auto-subscribe path is stamped with + the notifier's profile, so the notifier's owner-profile gate passes (delivers) + — assert stamped profile == notifier profile, and that a mismatched-creator + context still yields a deliverable sub. +2. **F2 test**: `/kanban create` from a thread source persists `session_id` on + the card (origin session), and `None` when created without a session context. +3. **F3 test**: a terminal transition for a card with an origin session resolves + that session/thread as the delivery target (not `webhook:kanban-transition:*`); + with no origin session it falls back to the default channel. +4. **E2E**: create card from thread → drive to `completed` → assert the notifier + delivery target is the origin thread AND the transition wake targets the origin + session. (Real imports, temp HERMES_HOME, no mock of the resolution chain.) + +## Definition of done (Casey's acceptance test) +Create a card FROM a specific thread, dispatch a real subagent, let it finish, +and — with Casey doing nothing — (1) a report lands in THAT thread, and (2) +Hollis wakes in that thread and takes the next step. "I watch it happen in a +thread, untouched." + +## Out of PR scope (ops, at deploy) +- Re-stamp existing mis-owned live subscriptions to `default`. +- Point the `kanban-transition` route's default fallback at `1515879019269197885`. +- Restart to load the merged code (restart-gated). diff --git a/gateway/kanban_transition_emit.py b/gateway/kanban_transition_emit.py index 939c7fb49a6e..349bb18cbfa8 100644 --- a/gateway/kanban_transition_emit.py +++ b/gateway/kanban_transition_emit.py @@ -78,13 +78,23 @@ def build_transition_payload( reason: Optional[str], event_id: int, title: str = "", + origin_session_id: Optional[str] = None, + origin_platform: Optional[str] = None, + origin_chat_id: Optional[str] = None, + origin_thread_id: Optional[str] = None, ) -> dict[str, Any]: """Build the JSON body POSTed to the kanban-transition route. The idempotency key is stable per ``(board, task_id, kind, event_id)`` so a webhook retry or a duplicate notifier tick converges on one agent run. + + The ``origin_*`` fields carry the thread/session this work was born in, so + the woken orchestrator reports back to that origin thread (the autonomy + contract) instead of a contextless webhook session. They are omitted from + the body when unknown (cron/webhook/direct-origin work), and the route's + handler falls back to the default channel. """ - return { + body: dict[str, Any] = { "task_id": task_id, "board": board, "kind": kind, @@ -104,6 +114,17 @@ def build_transition_payload( f"kanban-transition:{board}:{task_id}:{kind}:{event_id}" ), } + # Origin routing (only when known — keeps the body byte-stable for the + # no-origin case and lets the route fall back to the default channel). + if origin_session_id: + body["origin_session_id"] = origin_session_id + if origin_platform: + body["origin_platform"] = origin_platform + if origin_chat_id: + body["origin_chat_id"] = origin_chat_id + if origin_thread_id: + body["origin_thread_id"] = origin_thread_id + return body def _sign(secret: str, body: bytes) -> str: @@ -112,6 +133,47 @@ def _sign(secret: str, body: bytes) -> str: return "sha256=" + mac.hexdigest() +def resolve_transition_target( + *, + session_id: Optional[str], + sub: Optional[dict], + default_channel: str, +) -> dict[str, Any]: + """Resolve WHERE a transition wake should be delivered. + + The autonomy contract: work born in a thread reports back to THAT thread and + wakes THAT session (so both Casey sees it in the open AND Hollis resumes with + context). Only when a card has no origin (cron/webhook/direct work) do we + fall back to the default channel. + + Precedence: + 1. The card's origin session + its subscribed thread source (the thread it + was born in). ``is_fallback=False``. + 2. The default channel (Casey's #5), when there is no origin session and no + subscribed thread. ``is_fallback=True``. + + Never targets a throwaway ``webhook:kanban-transition:*`` session — that is + the F3 bug this replaces (the wake fired into a contextless session and died + in the log). + """ + if session_id or sub: + s = sub or {} + return { + "session_id": session_id or None, + "platform": s.get("platform") or None, + "chat_id": s.get("chat_id") or None, + "thread_id": s.get("thread_id") or None, + "is_fallback": False, + } + return { + "session_id": None, + "platform": None, + "chat_id": default_channel, + "thread_id": None, + "is_fallback": True, + } + + def route_url(cfg: dict) -> str: host = cfg.get("webhook_host", DEFAULT_WEBHOOK_HOST) port = int(cfg.get("webhook_port", DEFAULT_WEBHOOK_PORT)) diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index b4a982e9b97a..c8fac9cd4fd8 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -160,7 +160,13 @@ async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None: self._kanban_sub_fail_states = sub_fail_states notifier_profile = getattr(self, "_kanban_notifier_profile", None) if not notifier_profile: - notifier_profile = self._active_profile_name() + # Resolve via the shared canonical resolver so the notifier's + # owner-profile gate and every subscribe site agree on ONE value + # (config kanban.notifier_profile → active profile → "default"). + try: + notifier_profile = _kb.notifier_delivery_profile() + except Exception: + notifier_profile = self._active_profile_name() self._kanban_notifier_profile = notifier_profile # 4c — transition emit bridge (event-driven orchestration). When enabled @@ -435,6 +441,13 @@ def _collect(): reason_val = None if ev.payload and ev.payload.get("reason"): reason_val = str(ev.payload["reason"]) + # Carry the ORIGIN (session + thread) so the + # woken orchestrator reports back to the + # thread this work was born in, not a + # contextless webhook session. session_id + # comes from the card; the thread source + # from the subscription being delivered. + origin_sid = getattr(task, "session_id", None) if task else None payload = build_transition_payload( task_id=sub["task_id"], board=board_slug or "default", @@ -442,6 +455,10 @@ def _collect(): reason=reason_val, event_id=int(getattr(ev, "id", 0) or 0), title=title, + origin_session_id=origin_sid, + origin_platform=sub.get("platform"), + origin_chat_id=sub.get("chat_id"), + origin_thread_id=sub.get("thread_id"), ) await emit_transition( transition_emit_cfg, payload, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4b25d96fdbf9..ac3059e9e0e9 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -45,6 +45,31 @@ logger = logging.getLogger("gateway.run") +def _origin_session_key(source: SessionSource, config_extra: dict) -> str: + """Derive the origin session key EXACTLY as the live inbound path does. + + A card created from a thread stamps this key so a later terminal transition + can wake the originating session and report back to its thread. That wake + only lands if the stamped key is byte-identical to the key the inbound path + (``base.handle_message`` -> :func:`build_session_key`) builds for the same + source. So this mirrors inbound precisely: + + - read ``group_sessions_per_user`` / ``thread_sessions_per_user`` from the + platform's ``extra`` config (same source, same defaults), and + - pass NO profile — inbound omits it, so the namespace is ``agent:main``. + + Injecting a profile namespace or assuming default per-user flags (as an + earlier version did) makes the stamped key diverge under + ``thread_sessions_per_user: true`` or a non-default notifier profile, and + the transition then wakes a session that never existed. + """ + return build_session_key( + source, + group_sessions_per_user=config_extra.get("group_sessions_per_user", True), + thread_sessions_per_user=config_extra.get("thread_sessions_per_user", False), + ) + + class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" @@ -310,7 +335,6 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str: """ import asyncio import re - import shlex from hermes_cli.kanban import run_slash text = (event.text or "").strip() @@ -341,6 +365,22 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str: is_create = action == "create" + # F2 — stamp the ORIGIN session on a card created from a thread, so a + # later terminal transition can wake THAT session and report back to its + # origin thread (the autonomy contract). Only for `create`, only when the + # caller didn't already pass --session-id, and only when we can derive a + # session key from the message source. + if is_create and "--session-id" not in tokens and "--session-id" not in text: + try: + origin_session = _origin_session_key( + event.source, + getattr(getattr(self, "config", None), "extra", None) or {}, + ) + if origin_session: + text = f"{text} --session-id {shlex.quote(origin_session)}" + except Exception as exc: # pragma: no cover - defensive + logger.warning("kanban create origin-session stamp failed: %s", exc) + try: output = await asyncio.to_thread(run_slash, text) except Exception as exc: # pragma: no cover - defensive @@ -373,7 +413,13 @@ def _sub(): platform=platform_str, chat_id=chat_id, thread_id=thread_id or None, user_id=user_id, - notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(), + # Own the sub with the DELIVERING gateway's + # profile (canonical resolver), not the + # creator's — else the notifier drops it. + notifier_profile=( + getattr(self, "_kanban_notifier_profile", None) + or _kb.notifier_delivery_profile() + ), ) finally: conn.close() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d690b4c6b811..ea25f8e145b4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2330,6 +2330,13 @@ def _ensure_hermes_home_managed(home: Path): # decomposer prompt, model, or skills; configure that LLM path under # auxiliary.kanban_decomposer. "orchestrator_profile": "", + # Profile of the gateway that DELIVERS kanban terminal-event + # notifications. A notify-subscription must be owned by this profile or + # the notifier's owner-profile gate silently drops it (a sub stamped + # with a worker's profile never reaches the shared gateway). When unset, + # resolves to the active profile, then "default". Subscribe sites default + # to this via kanban_db.notifier_delivery_profile(). + "notifier_profile": "", # Where a child task lands if the orchestrator can't match an # assignee to any installed profile. When unset, falls back to the # default profile. A task never ends up with assignee=None. diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index e4f4d2539333..fbe423e4c33d 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -329,6 +329,11 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "and re-queues the task.") p_create.add_argument("--created-by", default="user", help="Author name recorded on the task (default: user)") + p_create.add_argument("--session-id", default=None, dest="session_id", + help="Origin session id (the thread/session this work " + "was born in). Persisted so a later terminal " + "transition can wake that session and report back " + "to its origin thread.") p_create.add_argument("--skill", action="append", default=[], dest="skills", help="Skill to force-load into the worker " "(repeatable). Appended to the built-in " @@ -1352,6 +1357,7 @@ def _cmd_create(args: argparse.Namespace) -> int: max_runtime_seconds=max_runtime, skills=getattr(args, "skills", None) or None, max_retries=max_retries, + session_id=getattr(args, "session_id", None) or None, goal_mode=bool(getattr(args, "goal_mode", False)), goal_max_turns=getattr(args, "goal_max_turns", None), max_iterations=getattr(args, "max_iterations", None), @@ -2451,7 +2457,12 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int: conn, task_id=args.task_id, platform=args.platform, chat_id=args.chat_id, thread_id=args.thread_id, user_id=args.user_id, - notifier_profile=args.notifier_profile or _profile_author(), + # Default to the profile of the gateway that will DELIVER the ping, + # NOT the creator's profile. A sub stamped with a worker profile + # (salton/avram/…) is silently dropped by the gateway notifier's + # owner-profile gate. An explicit --notifier-profile still wins for + # the rare multi-gateway case. + notifier_profile=args.notifier_profile or kb.notifier_delivery_profile(), ) print(f"Subscribed {args.platform}:{args.chat_id}" + (f":{args.thread_id}" if args.thread_id else "") diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f4f6abafdfba..9dcd843fbc0f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -7943,6 +7943,40 @@ def task_age(task: Task) -> dict: # Notification subscriptions (used by the gateway kanban-notifier) # --------------------------------------------------------------------------- +def notifier_delivery_profile() -> str: + """The profile of the gateway that will DELIVER kanban notifications. + + This is the single source of truth for notification ownership. A + notify-subscription must be stamped with THIS profile (not the profile of + whoever created the sub), because the notifier's owner-profile gate only + delivers subs whose ``notifier_profile`` matches the running notifier's + profile. A sub created under a worker profile (``salton``/``avram``/…) but + intended for the shared gateway must be owned by the delivering gateway, or + it is silently dropped. + + Resolution order (never raises, never returns empty): + 1. config ``kanban.notifier_profile`` (explicit operator setting), else + 2. the active profile (``get_active_profile_name()``), else + 3. ``"default"``. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + val = kanban_cfg.get("notifier_profile") if isinstance(kanban_cfg, dict) else None + if val: + return str(val) + except Exception: + pass + try: + from hermes_cli.profiles import get_active_profile_name + + return get_active_profile_name() or "default" + except Exception: + return "default" + + def add_notify_sub( conn: sqlite3.Connection, *, diff --git a/tests/gateway/test_thread_origin_autonomy.py b/tests/gateway/test_thread_origin_autonomy.py new file mode 100644 index 000000000000..971e75683a21 --- /dev/null +++ b/tests/gateway/test_thread_origin_autonomy.py @@ -0,0 +1,251 @@ +"""Thread-origin autonomy: report-back to the origin thread + wake Hollis. + +Covers three fixes that together close the loop so work moving through the +kanban system proactively reaches BOTH Casey (in his origin thread) and Hollis +(to proceed) — with no "status / update / check it" polling: + +F1 — a notify-subscription must be owned by the profile of the gateway that will + DELIVER it (the notifier's profile), not the profile of whoever created the + sub. Otherwise the notifier's owner-profile gate silently drops it. +F2 — a card created from a thread must persist its origin session_id, so a later + transition can wake that session. +F3 — a terminal transition must target the card's ORIGIN session/thread for + delivery, not a throwaway webhook session; fall back to the default channel + when there is no origin. + +Run with the hermes venv python: + ../hermes-agent/.venv/bin/python -m pytest tests/gateway/test_thread_origin_autonomy.py -q +""" +from __future__ import annotations + +import pytest + +from hermes_cli import kanban_db as kb + + +# ── F1: subscription ownership = the delivering gateway's profile ───────────── + +def test_notifier_delivery_profile_resolver_exists_and_is_stable(monkeypatch): + """There must be ONE canonical resolver for 'the profile that delivers + notifications', so the notifier gate and every subscribe site agree. + + It resolves from config `kanban.notifier_profile` first, then the active + profile, then 'default' — never raising. + """ + # Config value wins. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda *a, **k: {"kanban": {"notifier_profile": "default"}}, + ) + assert kb.notifier_delivery_profile() == "default" + + # Empty config value falls back to a non-empty default (never ""). + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda *a, **k: {"kanban": {"notifier_profile": ""}}, + ) + assert kb.notifier_delivery_profile() # truthy, not empty + + +def test_subscription_defaults_to_delivery_profile_not_creator(tmp_path, monkeypatch): + """A sub created under a WORKER profile must still be stamped with the + delivering gateway's profile, so the notifier gate passes. + + This is the F1 regression: subs were stamped with the creator's profile + (`salton`/`hollis`/…), which the `default` gateway notifier drops. + """ + db_path = tmp_path / "f1.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + + # The delivering gateway is 'default'. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda *a, **k: {"kanban": {"notifier_profile": "default"}}, + ) + # …but the CREATOR context is a worker profile 'salton'. + monkeypatch.setenv("HERMES_PROFILE_NAME", "salton") + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="worker-created", assignee="salton") + # Subscribe WITHOUT an explicit notifier_profile — must default to the + # delivering gateway's profile, not the creator's. + kb.add_notify_sub( + conn, task_id=tid, platform="discord", chat_id="c1", + notifier_profile=kb.notifier_delivery_profile(), + ) + subs = kb.list_notify_subs(conn) + mine = [s for s in subs if s["task_id"] == tid] + assert mine, "subscription must exist" + assert mine[0]["notifier_profile"] == "default", ( + "sub must be owned by the delivering gateway's profile ('default'), " + "not the creator's ('salton') — else the notifier drops it" + ) + finally: + conn.close() + + +# ── F2: cards persist their origin session_id ──────────────────────────────── + +def test_create_task_persists_origin_session_id(tmp_path, monkeypatch): + """A card created with an origin session must persist session_id so a later + transition can wake that session. (create_task already accepts it; this + locks the contract that it round-trips.)""" + db_path = tmp_path / "f2.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + + conn = kb.connect() + try: + sid = "agent:main:discord:thread:123:123" + tid = kb.create_task( + conn, title="thread-born", assignee="hollis", session_id=sid, + ) + task = kb.get_task(conn, tid) + assert task.session_id == sid, "origin session_id must persist on the card" + + # No session context => None (non-thread origin), not a crash. + tid2 = kb.create_task(conn, title="no-origin", assignee="hollis") + assert kb.get_task(conn, tid2).session_id in (None, ""), ( + "a card with no origin session must have empty session_id" + ) + finally: + conn.close() + + +# ── F3: transition targets the ORIGIN session/thread (fallback = default) ───── + +def test_resolve_transition_target_prefers_origin_then_falls_back(): + """The transition wake must resolve delivery to the card's ORIGIN + session/thread when present, else the default channel — never a throwaway + webhook session.""" + from gateway.kanban_transition_emit import resolve_transition_target + + DEFAULT_CHANNEL = "1515879019269197885" + + # Card with an origin session + thread source => target the origin. + origin = resolve_transition_target( + session_id="agent:main:discord:thread:123:123", + sub={"platform": "discord", "chat_id": "123", "thread_id": "123"}, + default_channel=DEFAULT_CHANNEL, + ) + assert origin["session_id"] == "agent:main:discord:thread:123:123" + assert origin["chat_id"] == "123" + assert origin["thread_id"] == "123" + assert origin.get("is_fallback") is False + + # No origin session and no sub => fall back to the default channel. + fb = resolve_transition_target( + session_id=None, sub=None, default_channel=DEFAULT_CHANNEL, + ) + assert fb["chat_id"] == DEFAULT_CHANNEL + assert fb.get("is_fallback") is True + # Never a throwaway webhook session. + assert "webhook:kanban-transition" not in str(fb.get("session_id") or "") + + +def test_build_payload_carries_origin_when_known_omits_when_not(): + """The transition payload carries origin session/thread when known (so the + woken orchestrator reports back to the origin thread), and omits them when + unknown (keeping the body byte-stable for the fallback case).""" + from gateway.kanban_transition_emit import build_transition_payload + + with_origin = build_transition_payload( + task_id="t_o", board="default", kind="completed", reason=None, event_id=5, + title="x", + origin_session_id="agent:main:discord:thread:9:9", + origin_platform="discord", origin_chat_id="9", origin_thread_id="9", + ) + assert with_origin["origin_session_id"] == "agent:main:discord:thread:9:9" + assert with_origin["origin_platform"] == "discord" + assert with_origin["origin_chat_id"] == "9" + assert with_origin["origin_thread_id"] == "9" + + without = build_transition_payload( + task_id="t_n", board="default", kind="completed", reason=None, event_id=6, + title="x", + ) + # No origin keys leak into the body when unknown. + for k in ("origin_session_id", "origin_platform", "origin_chat_id", "origin_thread_id"): + assert k not in without + # event_type still present (the merged classification fix must survive). + assert without["event_type"] == "completed" + + +# ── F2 wiring: the origin stamp key must MIRROR the live inbound key ────────── +# +# The F2 stamp on `/kanban create` derives the origin session key that a later +# transition wakes. If that derivation diverges from the key the live inbound +# path (base.handle_message -> build_session_key) produces for the SAME source, +# the transition wakes a session that never existed and the report-back dies in +# the log — reintroducing the exact defect this change closes. These tests lock +# the stamp derivation to the inbound derivation across the axes that break it. + +from gateway.session import Platform, SessionSource, build_session_key +from gateway.slash_commands import _origin_session_key + + +def _inbound_key(source, extra): + """The key the live inbound path builds for `source` (base.py:handle_message).""" + return build_session_key( + source, + group_sessions_per_user=extra.get("group_sessions_per_user", True), + thread_sessions_per_user=extra.get("thread_sessions_per_user", False), + ) + + +def _thread_source(): + return SessionSource( + platform=Platform.DISCORD, + chat_id="chan1", + chat_type="thread", + user_id="user1", + thread_id="thread1", + ) + + +def test_origin_stamp_key_matches_inbound_default_config(): + """Under default platform flags, the stamped origin key is byte-identical + to the live inbound key for the same source.""" + src = _thread_source() + extra = {} # defaults: group_sessions_per_user=True, thread_sessions_per_user=False + assert _origin_session_key(src, extra) == _inbound_key(src, extra) + + +def test_origin_stamp_key_matches_inbound_under_thread_per_user(): + """When `thread_sessions_per_user: true`, the inbound key appends the + participant id in a thread. The stamp MUST read the same flag and match — + the per-user axis that silently diverged when the stamp used default flags.""" + src = _thread_source() + extra = {"thread_sessions_per_user": True} + inbound = _inbound_key(src, extra) + # Sanity: the flag actually changes the inbound key (guards the test itself). + assert inbound != _inbound_key(src, {}), "flag must change the inbound key" + assert _origin_session_key(src, extra) == inbound + + +def test_origin_stamp_key_matches_inbound_under_group_per_user_off(): + """The stamp must mirror `group_sessions_per_user` too, not just assume the + default True.""" + src = SessionSource( + platform=Platform.DISCORD, + chat_id="chan1", + chat_type="group", + user_id="user1", + ) + extra = {"group_sessions_per_user": False} + inbound = _inbound_key(src, extra) + assert inbound != _inbound_key(src, {}), "flag must change the inbound key" + assert _origin_session_key(src, extra) == inbound + + +def test_origin_stamp_key_uses_agent_main_namespace_no_profile(): + """The inbound path passes NO profile, so its key namespace is `agent:main`. + The stamp must NOT inject a profile namespace (which would produce + `agent::...` and never match the live thread session).""" + src = _thread_source() + key = _origin_session_key(src, {}) + assert key.startswith("agent:main:"), ( + f"origin stamp must use the agent:main namespace like inbound, got {key!r}" + )