mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #72103 from victor-kyriazakos/feat/relay-slack-blockkit-native-parity
fix(relay): Slack DM-root prompts + flat-DM edit-streaming (native _resolve_thread_ts parity)
This commit is contained in:
commit
fc551f9691
5 changed files with 1415 additions and 47 deletions
|
|
@ -711,7 +711,56 @@ per-gateway secret and the same host as `/relay/provision`.
|
|||
|
||||
---
|
||||
|
||||
## 8. Versioning policy
|
||||
## 8. Gateway-side platform behavior controls (enterprise)
|
||||
|
||||
Enterprise deployments configure fronted-platform behavior on the GATEWAY
|
||||
side, under `platforms.relay.extra.<platform>` — a supported subset of that
|
||||
platform's native options. The native platform block (e.g. `platforms.slack`)
|
||||
is not read on the relay lane; the connector receives the *outcome* of these
|
||||
controls as frame metadata (§4) and executes mechanically — it holds no
|
||||
platform behavior policy of its own.
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
relay:
|
||||
extra:
|
||||
slack:
|
||||
reply_in_thread: true # default
|
||||
```
|
||||
|
||||
Resolution: nested `extra.<platform>` object wins → legacy flat key on
|
||||
`extra` honored as fallback → default. Source of truth:
|
||||
`RelayAdapter._effective_reply_in_thread` (`gateway/relay/adapter.py`).
|
||||
Values coerce exactly as the native Slack adapter's do — `1/true/yes/on`
|
||||
(case-insensitive, whitespace-trimmed) are ON, anything else is OFF — so a
|
||||
YAML-quoted `"false"` turns a knob off rather than being read as a truthy
|
||||
string.
|
||||
|
||||
Current controls (Slack):
|
||||
|
||||
| Key | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that `metadata.thread_id`). `false`: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. |
|
||||
| `dm_top_level_threads_as_sessions` | `true` | Native-parity escape hatch (mirrors `platforms.slack.extra.dm_top_level_threads_as_sessions`). `true`: in thread-per-message mode each top-level DM message keys its own session, so concurrent messages run in parallel. `false`: threaded reply placement is kept but the session stamp is skipped — one rolling DM session (legacy steer/queue posture). No effect in flat mode, which always keeps the single rolling session. |
|
||||
|
||||
Typing/status frames always carry the triggering-ts anchor when one is known
|
||||
(liveliness is unconditional, both modes): Slack's status line is
|
||||
thread-scoped, and in flat mode the send-side anchor strip guarantees the
|
||||
status anchor can never leak into reply placement. Semantics of the native
|
||||
key: see `website/docs/user-guide/messaging/slack.md`.
|
||||
|
||||
Thread-anchor resolution applies to EVERY send lane — text (`send`) and media
|
||||
(`send_media`) alike — through one choke point
|
||||
(`RelayAdapter._apply_slack_thread_anchor`). Media frames egress via the same
|
||||
connector-side Slack sender, which threads on `metadata.thread_id` only, so an
|
||||
attachment resolves its anchor identically to a text reply: promoted into
|
||||
metadata in thread-per-message mode, stripped in flat mode.
|
||||
|
||||
Changes take effect on gateway restart; no connector involvement.
|
||||
|
||||
---
|
||||
|
||||
## 9. Versioning policy
|
||||
|
||||
- `contract_version` is an int; bump **only** for additive changes during the
|
||||
experimental phase (new optional fields, new `op`s).
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
# recipient's author binding; we re-attach this user_id as
|
||||
# metadata.user_id on the outbound action so it can. See _capture_scope.
|
||||
self._dm_user_by_chat: Dict[str, str] = {}
|
||||
# chat_id -> chat_type (e.g. "dm", "channel", "group") learned from the
|
||||
# inbound event. Used to reproduce native Slack's synthetic-DM-thread
|
||||
# suppression on the relay lane: a DM streaming reply carries
|
||||
# reply_to=<triggering message ts> as its edit anchor, but the connector
|
||||
# maps a raw reply_to to a Slack thread_ts — so a plain DM reply would be
|
||||
# threaded UNDER the user's message (and lose progressive edit streaming)
|
||||
# instead of posting flat at the DM root. Native SlackAdapter drops that
|
||||
# synthetic reply_to in _resolve_thread_ts; the relay lane needs the same
|
||||
# disambiguation, and it needs the chat_type to know a chat is a DM.
|
||||
self._chat_type_by_chat: Dict[str, str] = {}
|
||||
# chat_id -> last triggering message ts (Slack). The typing/status
|
||||
# lane's synthetic thread anchor in thread-per-message mode;
|
||||
# see _capture_scope and send_typing.
|
||||
self._last_inbound_ts_by_chat: Dict[str, str] = {}
|
||||
# chat_id -> the UNDERLYING platform (e.g. "discord", "telegram") this
|
||||
# chat belongs to (Phase 1.5 multi-platform-per-agent). One relay adapter
|
||||
# fronts N platforms on one WS; an outbound reply must egress through the
|
||||
|
|
@ -123,6 +137,27 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
def message_len_fn(self) -> Callable[[str], int]:
|
||||
return _LEN_FNS.get(self.descriptor.len_unit, len)
|
||||
|
||||
@property
|
||||
def supports_status_text(self) -> bool: # type: ignore[override]
|
||||
"""Whether the fronted platform renders a TEXT status line.
|
||||
|
||||
Native parity (rich status text): Slack's typing surface is the
|
||||
assistant status line ("Finding answers…" next to the bot name), a
|
||||
text-rendering indicator. When the relay fronts Slack, advertise it so
|
||||
run.py's live-status lane feeds per-tool phrases via
|
||||
``set_status_text()`` — exactly the wiring the native SlackAdapter
|
||||
gets (``supports_status_text = True``). Other fronted platforms keep
|
||||
textless typing bubbles and must NOT receive phrase traffic.
|
||||
|
||||
Property (not class attr) because ONE RelayAdapter class fronts many
|
||||
platforms; the answer depends on the handshaked descriptor. On a
|
||||
multi-platform relay this scalar reflects the PRIMARY identity's
|
||||
platform (same convention as the scalar ``descriptor``); per-chat
|
||||
egress paths that need chat-accurate capabilities use
|
||||
``_descriptor_for_chat`` below.
|
||||
"""
|
||||
return self.descriptor.platform == Platform.SLACK.value
|
||||
|
||||
# ── per-chat capability resolution (Phase 1.5 multi-platform) ─────────
|
||||
def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor:
|
||||
"""The capability descriptor governing a specific chat.
|
||||
|
|
@ -279,6 +314,7 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
async def _on_inbound(self, event) -> None:
|
||||
"""Bridge a connector-delivered MessageEvent into the normal adapter path."""
|
||||
self._capture_scope(event)
|
||||
self._stamp_slack_session_thread(event)
|
||||
# Phase 3: a structured prompt answer resolves its waiting primitive
|
||||
# (approval/confirm/clarify) and is CONSUMED — it must not also
|
||||
# dispatch as a chat message. Unknown/expired prompt ids fall through
|
||||
|
|
@ -288,6 +324,112 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
await self._localize_inbound_media(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
def _relay_slack_extra(self) -> Dict[str, Any]:
|
||||
"""The Slack-behavior subset of the RELAY platform config.
|
||||
|
||||
Enterprise knob shape (Hermes-config directed, relay-namespaced):
|
||||
|
||||
platforms:
|
||||
relay:
|
||||
extra:
|
||||
slack: # supported subset of native Slack fields
|
||||
reply_in_thread: true
|
||||
|
||||
The native ``platforms.slack`` block keeps meaning "native adapter
|
||||
settings"; relay-fronted Slack reads its subset here. Legacy fallback:
|
||||
a flat key on the relay extra (``extra.reply_in_thread``) still wins
|
||||
when no ``slack`` object exists, preserving current staging configs.
|
||||
"""
|
||||
extra = getattr(self.config, "extra", None) or {}
|
||||
sub = extra.get("slack")
|
||||
return sub if isinstance(sub, dict) else extra
|
||||
|
||||
@staticmethod
|
||||
def _coerce_flag(raw: Any, default: bool) -> bool:
|
||||
"""Coerce an operator-supplied boolean exactly as native Slack does.
|
||||
|
||||
Native SlackAdapter reads its behavior flags with
|
||||
``str(raw).strip().lower() in {"1","true","yes","on"}``, so a
|
||||
YAML-quoted ``"false"`` — a shape operators write routinely — turns
|
||||
the flag OFF. A bare ``bool()`` would read that same string as True
|
||||
(non-empty string), silently ignoring the off switch. These knobs are
|
||||
documented as native-parity mirrors, so they must coerce identically
|
||||
or the parity claim only holds for unquoted YAML booleans.
|
||||
"""
|
||||
if raw is None:
|
||||
return default
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _effective_reply_in_thread(self) -> bool:
|
||||
"""Resolve the thread-per-message vs flat-DM mode for fronted Slack."""
|
||||
try:
|
||||
return self._coerce_flag(
|
||||
self._relay_slack_extra().get("reply_in_thread"), True
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config shape is operator-owned
|
||||
return True
|
||||
|
||||
def _dm_top_level_threads_as_sessions(self) -> bool:
|
||||
"""Native-parity escape hatch: per-message DM sessions on/off.
|
||||
|
||||
Mirrors native SlackAdapter._dm_top_level_threads_as_sessions
|
||||
(platforms.slack.extra.dm_top_level_threads_as_sessions). Default
|
||||
True: in thread-per-message mode each top-level DM message keys its
|
||||
own session (parallel turns). Set
|
||||
platforms.relay.extra.slack.dm_top_level_threads_as_sessions: false
|
||||
to keep threaded reply PLACEMENT but ONE rolling DM session — the
|
||||
legacy steer/queue posture, decoupled from reply_in_thread.
|
||||
"""
|
||||
try:
|
||||
return self._coerce_flag(
|
||||
self._relay_slack_extra().get("dm_top_level_threads_as_sessions"),
|
||||
True,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config shape is operator-owned
|
||||
return True
|
||||
|
||||
def _stamp_slack_session_thread(self, event) -> None:
|
||||
"""Native session-keying parity for fronted Slack DMs.
|
||||
|
||||
Native SlackAdapter's inbound handler stamps ``thread_ts =
|
||||
event.thread_ts or ts`` — every TOP-LEVEL message carries its own ts
|
||||
as ``source.thread_id``, so build_session_key appends it and each
|
||||
top-level message gets a FRESH session (per-message threads ⇒
|
||||
per-message sessions; a 2nd message runs parallel instead of steering
|
||||
the in-flight turn). The connector normalizes a top-level message
|
||||
with thread_id=null, so without this stamp every top-level DM
|
||||
collapses into ONE session key and message 2 pre-empts message 1
|
||||
("Redirected current run", 2026-07-27 report).
|
||||
|
||||
Only in thread-per-message mode: flat mode keeps the shared rolling
|
||||
DM session on purpose (steer/queue there is the intended UX). Never
|
||||
overwrites a real thread_id (an in-thread reply must keep resolving
|
||||
to its thread's session).
|
||||
"""
|
||||
try:
|
||||
src = getattr(event, "source", None)
|
||||
if not src:
|
||||
return
|
||||
platform = getattr(src, "platform", None)
|
||||
if getattr(platform, "value", platform) != Platform.SLACK.value:
|
||||
return
|
||||
if getattr(src, "thread_id", None):
|
||||
return # real thread — its session key is already correct
|
||||
message_id = getattr(event, "message_id", None) or getattr(
|
||||
src, "message_id", None
|
||||
)
|
||||
if not message_id:
|
||||
return
|
||||
if not self._effective_reply_in_thread():
|
||||
return
|
||||
if not self._dm_top_level_threads_as_sessions():
|
||||
return # opt-out: threaded replies, one rolling session
|
||||
src.thread_id = str(message_id)
|
||||
except Exception: # noqa: BLE001 - session stamping must never break inbound
|
||||
logger.debug("slack session-thread stamp failed", exc_info=True)
|
||||
|
||||
async def _localize_inbound_media(self, event) -> None:
|
||||
"""Download connector re-hosted attachments to local temp paths.
|
||||
|
||||
|
|
@ -380,10 +522,32 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
scope = getattr(src, "scope_id", None)
|
||||
if scope:
|
||||
self._scope_by_chat[str(chat)] = str(scope)
|
||||
# Remember the chat_type so send() can suppress the synthetic-DM
|
||||
# thread anchor on Slack (native _resolve_thread_ts parity). send()
|
||||
# only receives a chat_id, so it needs this per-chat cache to know a
|
||||
# chat is a DM.
|
||||
chat_type = getattr(src, "chat_type", None)
|
||||
if chat_type:
|
||||
self._chat_type_by_chat[str(chat)] = str(chat_type)
|
||||
# Triggering message ts: the typing/status lane's metadata
|
||||
# (base.py _thread_metadata_for_source) carries NO thread anchor
|
||||
# for a top-level DM, but in thread-per-message mode the status
|
||||
# must target the per-message thread (its root = this ts). Cache
|
||||
# it per chat so send_typing can synthesize the anchor, mirroring
|
||||
# native send_typing's _resolve_thread_ts(metadata.message_id).
|
||||
# NOTE: message_id lives on the EVENT (MessageEvent), not the
|
||||
# source — fall back to source for defensive coverage.
|
||||
message_id = getattr(event, "message_id", None) or getattr(
|
||||
src, "message_id", None
|
||||
)
|
||||
if message_id:
|
||||
self._last_inbound_ts_by_chat[str(chat)] = str(message_id)
|
||||
except Exception: # noqa: BLE001 - scope tracking must never break inbound
|
||||
pass
|
||||
|
||||
def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def _with_scope(
|
||||
self, chat_id: str, metadata: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Ensure the outbound metadata carries the discriminator(s) the connector's
|
||||
egress guard needs to resolve the owning tenant.
|
||||
|
||||
|
|
@ -542,16 +706,26 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
else:
|
||||
text = ""
|
||||
member = payload.get("member") or {}
|
||||
user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {}
|
||||
user = (
|
||||
(member.get("user") if isinstance(member, dict) else None)
|
||||
or payload.get("user")
|
||||
or {}
|
||||
)
|
||||
channel_id = str(payload.get("channel_id") or "")
|
||||
guild_id = payload.get("guild_id") # real Discord interaction wire field
|
||||
source = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=channel_id,
|
||||
chat_type="channel" if guild_id else "dm",
|
||||
user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None,
|
||||
user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None,
|
||||
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot
|
||||
user_id=str(user.get("id"))
|
||||
if isinstance(user, dict) and user.get("id")
|
||||
else None,
|
||||
user_name=str(user.get("username"))
|
||||
if isinstance(user, dict) and user.get("username")
|
||||
else None,
|
||||
scope_id=str(guild_id)
|
||||
if guild_id
|
||||
else None, # Discord guild → generic scope slot
|
||||
message_id=str(payload.get("id")) if payload.get("id") else None,
|
||||
)
|
||||
event = MessageEvent(text=text, message_type=message_type, source=source)
|
||||
|
|
@ -567,7 +741,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
prompt_id, option_id = decoded
|
||||
msg = payload.get("message") or {}
|
||||
prompt_message_id = (
|
||||
str(msg.get("id")) if isinstance(msg, dict) and msg.get("id") else None
|
||||
str(msg.get("id"))
|
||||
if isinstance(msg, dict) and msg.get("id")
|
||||
else None
|
||||
)
|
||||
event.prompt_response = {
|
||||
"prompt_id": prompt_id,
|
||||
|
|
@ -620,7 +796,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
sub_name = str(opt.get("name") or "").strip()
|
||||
if sub_name:
|
||||
parts.append(sub_name)
|
||||
parts.extend(RelayAdapter._render_interaction_options(opt.get("options")))
|
||||
parts.extend(
|
||||
RelayAdapter._render_interaction_options(opt.get("options"))
|
||||
)
|
||||
else:
|
||||
value = opt.get("value")
|
||||
if value is not None and str(value).strip():
|
||||
|
|
@ -744,12 +922,19 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
)
|
||||
if self._transport is None:
|
||||
return SendResult(success=False, error="no transport")
|
||||
# Native _resolve_thread_ts parity: a Slack DM reply must post flat at
|
||||
# the DM root, not threaded under the triggering message. One shared
|
||||
# helper resolves the anchor for EVERY egress lane (see
|
||||
# _apply_slack_thread_anchor) so the text and media lanes cannot drift.
|
||||
effective_reply_to = self._apply_slack_thread_anchor(
|
||||
chat_id, reply_to, send_metadata
|
||||
)
|
||||
result = await self._transport.send_outbound(
|
||||
{
|
||||
"op": "send",
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"reply_to": reply_to,
|
||||
"reply_to": effective_reply_to,
|
||||
"metadata": self._with_scope(chat_id, send_metadata),
|
||||
},
|
||||
platform=self._platform_by_chat.get(str(chat_id)),
|
||||
|
|
@ -760,6 +945,144 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
error=result.get("error"),
|
||||
)
|
||||
|
||||
def _resolve_reply_to_for_send(
|
||||
self,
|
||||
chat_id: str,
|
||||
reply_to: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Suppress the synthetic-DM thread anchor for a Slack DM reply.
|
||||
|
||||
A DM turn's streaming reply is sent with ``reply_to`` = the triggering
|
||||
message's ts (the stream consumer's ``initial_reply_to_id``, used as the
|
||||
edit anchor and, on threading platforms, the reply target). The
|
||||
connector's slackRestSender maps a raw ``reply_to`` to a Slack
|
||||
``thread_ts``, so a plain DM reply would be posted THREADED under the
|
||||
user's message instead of flat at the DM root — and a threaded first
|
||||
send loses the progressive edit-streaming the user sees in a real
|
||||
thread (the reported symptom: DM/home replies arrive flat, no
|
||||
progressive edits).
|
||||
|
||||
Native Slack Hermes already suppresses this synthetic DM thread anchor:
|
||||
``SlackAdapter._resolve_thread_ts`` returns ``None`` for a top-level /
|
||||
DM message when ``reply_in_thread`` is off. The relay lane has no such
|
||||
disambiguation, so we reproduce it here. run.py already encodes the
|
||||
real-thread decision in ``metadata["thread_id"]`` (it is set only when
|
||||
progress threading is active — a real thread, or channel autoThread);
|
||||
for a DM with no real thread that key is absent. So the rule is:
|
||||
|
||||
Slack DM + no real ``thread_id`` in metadata ⇒ drop ``reply_to``.
|
||||
|
||||
This posts the reply flat at the DM root and lets the consumer edit its
|
||||
own first-send ts — streaming works exactly as in a thread. It does NOT:
|
||||
* reintroduce a synthetic DM thread_id (#18859 / the /sethome
|
||||
landmine) — it removes an anchor, never adds one;
|
||||
* regress real-thread streaming — a real thread carries a distinct
|
||||
``thread_id`` in metadata, so the guard leaves ``reply_to`` alone;
|
||||
* regress channel autoThread — a channel/group top-level reply carries
|
||||
``thread_id`` (the message's own ts) in metadata when threading is
|
||||
on, so it is left alone; and a non-DM chat is never matched here.
|
||||
"""
|
||||
if reply_to is None:
|
||||
return None
|
||||
if self._platform_by_chat.get(str(chat_id)) != Platform.SLACK.value:
|
||||
return reply_to
|
||||
if self._chat_type_by_chat.get(str(chat_id)) != "dm":
|
||||
return reply_to
|
||||
md = metadata or {}
|
||||
if md.get("thread_id") or md.get("thread_ts"):
|
||||
# A real thread was resolved by run.py — honour it.
|
||||
return reply_to
|
||||
# Mode gate (native _resolve_thread_ts parity). The final-reply lane
|
||||
# (gateway/platforms/base.py) builds metadata from source.thread_id
|
||||
# ONLY — for a top-level DM that is None, so in thread-per-message
|
||||
# mode the triggering-ts reply_to here is the final reply's ONLY
|
||||
# threading signal (run.py's synthetic root feeds just the
|
||||
# progress/status lane). Dropping it unconditionally exiled the final
|
||||
# message to the DM root while progress stayed threaded (2026-07-27
|
||||
# report, same class as the prompt-placement bug). Native SlackAdapter only
|
||||
# suppresses the anchor when reply_in_thread=false; mirror that.
|
||||
reply_in_thread = self._effective_reply_in_thread()
|
||||
if reply_in_thread:
|
||||
# Thread-per-message: the triggering ts is the thread anchor.
|
||||
return reply_to
|
||||
# Flat mode: synthetic DM self-anchor — post flat at the DM root.
|
||||
return None
|
||||
|
||||
def _apply_slack_thread_anchor(
|
||||
self,
|
||||
chat_id: str,
|
||||
reply_to: Optional[str],
|
||||
metadata: Dict[str, Any],
|
||||
*,
|
||||
mirror_key: str = "reply_to_message_id",
|
||||
) -> Optional[str]:
|
||||
"""Resolve the outbound Slack thread anchor for ONE egress frame.
|
||||
|
||||
The single choke point every send lane goes through — text (``send``)
|
||||
and media (``send_media``) alike. It does three things that must always
|
||||
happen together, and previously only happened on the text lane:
|
||||
|
||||
1. Mode gate: ``_resolve_reply_to_for_send`` drops the synthetic DM
|
||||
self-anchor in flat mode, keeps it in thread-per-message mode.
|
||||
2. Mirror strip: when the anchor is dropped, remove the mirrored
|
||||
``metadata.reply_to_message_id`` too, so the connector cannot
|
||||
thread on the copy we forgot about.
|
||||
3. Anchor promotion: the connector's Slack sender THREADS ON METADATA
|
||||
ONLY — ``threadTs()`` reads ``metadata.thread_id``/``thread_ts``
|
||||
and never looks at the frame's ``reply_to``. A surviving anchor is
|
||||
promoted into ``metadata.thread_id`` or the message silently lands
|
||||
in the home channel instead of the per-message thread.
|
||||
|
||||
``metadata`` is mutated in place; the effective ``reply_to`` is
|
||||
returned. Non-Slack and non-DM chats are untouched by (1), and (3) is
|
||||
Slack-only, so other fronted platforms keep their existing behaviour.
|
||||
"""
|
||||
effective_reply_to = self._resolve_reply_to_for_send(
|
||||
chat_id, reply_to, metadata
|
||||
)
|
||||
if effective_reply_to is None and reply_to is not None:
|
||||
metadata.pop(mirror_key, None)
|
||||
if (
|
||||
effective_reply_to is not None
|
||||
and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value
|
||||
and not (metadata.get("thread_id") or metadata.get("thread_ts"))
|
||||
):
|
||||
metadata["thread_id"] = str(effective_reply_to)
|
||||
return effective_reply_to
|
||||
|
||||
def _with_status_thread_anchor(
|
||||
self, chat_id: str, metadata: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy ``metadata`` with the typing/status thread anchor applied.
|
||||
|
||||
Slack's status line is THREAD-scoped: the connector's typing case
|
||||
no-ops without a thread anchor, and the typing lane's metadata
|
||||
(base.py ``_thread_metadata_for_source``) carries none for a top-level
|
||||
DM (``source.thread_id`` is None). Synthesize it from the per-chat
|
||||
inbound-ts cache, exactly as native ``send_typing`` resolves
|
||||
``thread_ts`` from ``metadata.message_id``.
|
||||
|
||||
Unconditional across both modes: in flat mode the send lane strips its
|
||||
own anchors (see ``_apply_slack_thread_anchor``), so the status anchor
|
||||
cannot leak into reply placement, and ``setStatus`` clears without
|
||||
leaving a message artifact.
|
||||
|
||||
Shared by ``send_typing`` and ``stop_typing`` — the clear MUST target
|
||||
the same thread the heartbeat set, or the status line sticks until
|
||||
Slack's own timeout. Keeping one implementation is what guarantees it.
|
||||
"""
|
||||
md = dict(metadata or {})
|
||||
if (
|
||||
not (md.get("thread_id") or md.get("thread_ts"))
|
||||
and self._platform_by_chat.get(str(chat_id)) == Platform.SLACK.value
|
||||
and self._chat_type_by_chat.get(str(chat_id)) == "dm"
|
||||
):
|
||||
anchor = self._last_inbound_ts_by_chat.get(str(chat_id))
|
||||
if anchor:
|
||||
md["thread_id"] = anchor
|
||||
return md
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -817,13 +1140,39 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
"""
|
||||
if self._transport is None:
|
||||
return
|
||||
# Thread anchor for the status surface. Slack's status line
|
||||
# ("is thinking…" in the thread's replies footer — works with plain
|
||||
# chat:write, confirmed on native no-assistant bots) is THREAD-only:
|
||||
# the connector's typing case no-ops without a thread_ts. But the
|
||||
# typing lane's metadata (base.py _thread_metadata_for_source) has no
|
||||
# anchor for a top-level DM — source.thread_id is None — so every
|
||||
# heartbeat was silently dropped. In thread-per-message mode the
|
||||
# turn's thread root IS the triggering message ts (run.py's synthetic
|
||||
# root); synthesize it here from the per-chat inbound cache, exactly
|
||||
# like native send_typing resolves thread_ts from metadata.message_id.
|
||||
# Flat mode (reply_in_thread=false) keeps the no-anchor no-op: there
|
||||
# is no thread and must not be one (#18859).
|
||||
md = self._with_status_thread_anchor(chat_id, metadata)
|
||||
# Rich status parity: run.py's live-status lane stashes the
|
||||
# current per-tool phrase via set_status_text() (base class store).
|
||||
# Carry it as the typing frame's content so the connector's Slack
|
||||
# sender renders it on assistant.threads.setStatus — the same phrase
|
||||
# the native adapter shows ("is running pytest…", "Finding answers…").
|
||||
# Absent (None/empty) => omit content; the connector falls back to its
|
||||
# default "is typing…" heartbeat, preserving pre-phrase behaviour on
|
||||
# every platform. Never send empty-string content here: on Slack that
|
||||
# is the explicit CLEAR request reserved for stop_typing.
|
||||
frame: Dict[str, Any] = {
|
||||
"op": "typing",
|
||||
"chat_id": chat_id,
|
||||
"metadata": self._with_scope(chat_id, md),
|
||||
}
|
||||
phrase = getattr(self, "_status_text", {}).get(str(chat_id))
|
||||
if phrase:
|
||||
frame["content"] = str(phrase)
|
||||
try:
|
||||
await self._transport.send_outbound(
|
||||
{
|
||||
"op": "typing",
|
||||
"chat_id": chat_id,
|
||||
"metadata": self._with_scope(chat_id, metadata),
|
||||
},
|
||||
frame,
|
||||
platform=self._platform_by_chat.get(str(chat_id)),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - typing is cosmetic, never breaks a turn
|
||||
|
|
@ -852,13 +1201,17 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
platform = self._platform_by_chat.get(str(chat_id))
|
||||
if platform != Platform.SLACK.value:
|
||||
return
|
||||
# Clear must target the SAME thread the heartbeat set, or the clear
|
||||
# frame no-ops threadless and the status line sticks until Slack's own
|
||||
# timeout. Shared helper with send_typing so the two guards cannot drift.
|
||||
md = self._with_status_thread_anchor(chat_id, metadata)
|
||||
try:
|
||||
await self._transport.send_outbound(
|
||||
{
|
||||
"op": "typing",
|
||||
"chat_id": chat_id,
|
||||
"content": "",
|
||||
"metadata": self._with_scope(chat_id, metadata),
|
||||
"metadata": self._with_scope(chat_id, md),
|
||||
},
|
||||
platform=platform,
|
||||
)
|
||||
|
|
@ -983,14 +1336,23 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if not uploaded:
|
||||
return None
|
||||
source_url = uploaded
|
||||
# Same Slack thread-anchor contract as the text lane (send). Media
|
||||
# frames egress through the connector's Slack sender too, so an
|
||||
# unresolved anchor threads an image under the user's DM message in
|
||||
# flat mode, and loses the per-message thread entirely in thread mode
|
||||
# (threadTs() reads metadata only). Route through the shared helper.
|
||||
media_metadata: Dict[str, Any] = dict(metadata or {})
|
||||
effective_reply_to = self._apply_slack_thread_anchor(
|
||||
chat_id, reply_to, media_metadata
|
||||
)
|
||||
action: Dict[str, Any] = {
|
||||
"op": "send_media",
|
||||
"chat_id": chat_id,
|
||||
"media_kind": media_kind,
|
||||
"source_url": source_url,
|
||||
"content": caption or "",
|
||||
"reply_to": reply_to,
|
||||
"metadata": self._with_scope(chat_id, metadata),
|
||||
"reply_to": effective_reply_to,
|
||||
"metadata": self._with_scope(chat_id, media_metadata),
|
||||
}
|
||||
if filename:
|
||||
action["filename"] = filename
|
||||
|
|
@ -1065,8 +1427,12 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if result is not None:
|
||||
return result
|
||||
return await super().send_image_file(
|
||||
chat_id, image_path, caption=caption, reply_to=reply_to,
|
||||
metadata=metadata, **kwargs,
|
||||
chat_id,
|
||||
image_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def send_voice(
|
||||
|
|
@ -1091,8 +1457,12 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if result is not None:
|
||||
return result
|
||||
return await super().send_voice(
|
||||
chat_id, audio_path, caption=caption, reply_to=reply_to,
|
||||
metadata=metadata, **kwargs,
|
||||
chat_id,
|
||||
audio_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def send_video(
|
||||
|
|
@ -1117,8 +1487,12 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if result is not None:
|
||||
return result
|
||||
return await super().send_video(
|
||||
chat_id, video_path, caption=caption, reply_to=reply_to,
|
||||
metadata=metadata, **kwargs,
|
||||
chat_id,
|
||||
video_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def send_document(
|
||||
|
|
@ -1145,13 +1519,20 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if result is not None:
|
||||
return result
|
||||
return await super().send_document(
|
||||
chat_id, file_path, caption=caption, file_name=file_name,
|
||||
reply_to=reply_to, metadata=metadata, **kwargs,
|
||||
chat_id,
|
||||
file_path,
|
||||
caption=caption,
|
||||
file_name=file_name,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# ── Phase 3 interactive: prompt + react ──────────────────────────────
|
||||
|
||||
def _mint_prompt(self, kind: str, state: Dict[str, Any], timeout_s: float = 3600.0) -> str:
|
||||
def _mint_prompt(
|
||||
self, kind: str, state: Dict[str, Any], timeout_s: float = 3600.0
|
||||
) -> str:
|
||||
"""Register a pending prompt and return its 8-hex id.
|
||||
|
||||
``state`` carries what the resolver needs when the answer comes back
|
||||
|
|
@ -1171,7 +1552,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
# Opportunistic sweep so abandoned prompts can't accumulate: drop
|
||||
# anything already expired (cheap — dict is small by construction).
|
||||
now = time.time()
|
||||
for stale in [k for k, v in self._pending_prompts.items() if v.get("expires_at", 0) < now]:
|
||||
for stale in [
|
||||
k for k, v in self._pending_prompts.items() if v.get("expires_at", 0) < now
|
||||
]:
|
||||
self._pending_prompts.pop(stale, None)
|
||||
return prompt_id
|
||||
|
||||
|
|
@ -1207,6 +1590,20 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
"""
|
||||
if self._transport is None or not self.descriptor.supports_op("prompt"):
|
||||
return None
|
||||
# An interactive prompt (approval / clarify / slash-confirm) is emitted
|
||||
# mid-turn in reply to the triggering inbound event, so `metadata` carries
|
||||
# that event's thread context (run.py _thread_metadata_for_source stamps
|
||||
# metadata.thread_id — for a Slack DM the triggering message's own ts,
|
||||
# used only as a session-keying fallback). Forwarding it makes the
|
||||
# connector thread the prompt card UNDER the triggering message instead
|
||||
# of posting it flat at the DM root (the reported bug). Native Slack
|
||||
# Hermes suppresses this synthetic DM thread anchor; drop it here for the
|
||||
# same Slack-DM-with-no-real-thread case, matching _resolve_reply_to_for_send.
|
||||
# Prompt metadata is forwarded VERBATIM. The threading mode is decided
|
||||
# in exactly one place — run.py's _resolve_progress_thread_id (flat mode
|
||||
# suppresses the synthetic self-anchor there; thread mode stamps the
|
||||
# turn's thread). Boundary pinned by test_run_py_suppresses_self_anchor*.
|
||||
prompt_metadata = metadata
|
||||
action: Dict[str, Any] = {
|
||||
"op": "prompt",
|
||||
"chat_id": chat_id,
|
||||
|
|
@ -1214,8 +1611,10 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
"prompt_kind": prompt_kind,
|
||||
"prompt_id": prompt_id,
|
||||
"options": options,
|
||||
"reply_to": reply_to,
|
||||
"metadata": self._with_scope(chat_id, metadata),
|
||||
"reply_to": self._resolve_reply_to_for_send(
|
||||
chat_id, reply_to, prompt_metadata
|
||||
),
|
||||
"metadata": self._with_scope(chat_id, prompt_metadata),
|
||||
}
|
||||
if timeout_s is not None:
|
||||
action["timeout_s"] = int(timeout_s)
|
||||
|
|
@ -1260,12 +1659,15 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
button→text fallback takes over (same contract as a native adapter's
|
||||
failed button send).
|
||||
"""
|
||||
options: list = [{"id": "once", "label": "✅ Allow Once", "style": "success"}]
|
||||
options: list = [{"id": "once", "label": "Allow Once", "style": "primary"}]
|
||||
if not smart_denied and allow_session:
|
||||
options.append({"id": "session", "label": "✅ Session", "style": "primary"})
|
||||
options.append({"id": "session", "label": "Allow Session"})
|
||||
if allow_permanent:
|
||||
options.append({"id": "always", "label": "✅ Always", "style": "primary"})
|
||||
options.append({"id": "deny", "label": "❌ Deny", "style": "danger"})
|
||||
options.append({
|
||||
"id": "always",
|
||||
"label": "Always Allow",
|
||||
})
|
||||
options.append({"id": "deny", "label": "Deny", "style": "danger"})
|
||||
|
||||
cmd_preview = command if len(command) <= 1500 else command[:1500] + "..."
|
||||
text = (
|
||||
|
|
@ -1274,7 +1676,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
f"Reason: {description}"
|
||||
)
|
||||
if smart_denied:
|
||||
text += "\n\n**Smart DENY:** owner override applies to this one operation only."
|
||||
text += (
|
||||
"\n\n**Smart DENY:** owner override applies to this one operation only."
|
||||
)
|
||||
|
||||
prompt_id = self._mint_prompt(
|
||||
"exec_approval",
|
||||
|
|
@ -1310,9 +1714,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
gateway's text-intercept flow when the prompt lane is unavailable.
|
||||
"""
|
||||
options = [
|
||||
{"id": "once", "label": "✅ Approve Once", "style": "success"},
|
||||
{"id": "always", "label": "🔒 Always Approve", "style": "primary"},
|
||||
{"id": "cancel", "label": "❌ Cancel", "style": "danger"},
|
||||
{"id": "once", "label": "Approve Once", "style": "primary"},
|
||||
{"id": "always", "label": "Always Approve"},
|
||||
{"id": "cancel", "label": "Cancel", "style": "danger"},
|
||||
]
|
||||
text = f"**{title}**\n\n{message}" if title else message
|
||||
prompt_id = self._mint_prompt(
|
||||
|
|
@ -1422,7 +1826,11 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
if kind == "exec_approval":
|
||||
from tools.approval import resolve_gateway_approval
|
||||
|
||||
choice = option_id if option_id in {"once", "session", "always", "deny"} else "deny"
|
||||
choice = (
|
||||
option_id
|
||||
if option_id in {"once", "session", "always", "deny"}
|
||||
else "deny"
|
||||
)
|
||||
count = resolve_gateway_approval(session_key, choice)
|
||||
label = {
|
||||
"once": "✅ Approved once",
|
||||
|
|
@ -1435,13 +1843,17 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
# Acknowledge in-channel (the connector's prompt message can't
|
||||
# be edited cross-platform yet — edit support varies; a short
|
||||
# confirmation preserves the audit trail the native edit gives).
|
||||
await self.send(chat_id, label, metadata=self._prompt_reply_metadata(event))
|
||||
await self.send(
|
||||
chat_id, label, metadata=self._prompt_reply_metadata(event)
|
||||
)
|
||||
if count:
|
||||
self.resume_typing_for_chat(chat_id)
|
||||
elif kind == "slash_confirm":
|
||||
from tools import slash_confirm as slash_confirm_mod
|
||||
|
||||
choice = option_id if option_id in {"once", "always", "cancel"} else "cancel"
|
||||
choice = (
|
||||
option_id if option_id in {"once", "always", "cancel"} else "cancel"
|
||||
)
|
||||
result_text = await slash_confirm_mod.resolve(
|
||||
session_key, str(state.get("confirm_id") or ""), choice
|
||||
)
|
||||
|
|
@ -1450,13 +1862,20 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
"always": "🔒 Always approve",
|
||||
"cancel": "❌ Cancelled",
|
||||
}.get(choice, "Resolved")
|
||||
await self.send(chat_id, label, metadata=self._prompt_reply_metadata(event))
|
||||
await self.send(
|
||||
chat_id, label, metadata=self._prompt_reply_metadata(event)
|
||||
)
|
||||
if result_text:
|
||||
await self.send(
|
||||
chat_id, str(result_text), metadata=self._prompt_reply_metadata(event)
|
||||
chat_id,
|
||||
str(result_text),
|
||||
metadata=self._prompt_reply_metadata(event),
|
||||
)
|
||||
elif kind == "clarify":
|
||||
from tools.clarify_gateway import mark_awaiting_text, resolve_gateway_clarify
|
||||
from tools.clarify_gateway import (
|
||||
mark_awaiting_text,
|
||||
resolve_gateway_clarify,
|
||||
)
|
||||
|
||||
clarify_id = str(state.get("clarify_id") or "")
|
||||
if option_id == "other":
|
||||
|
|
|
|||
|
|
@ -21924,11 +21924,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_slack_adapter_for_progress = self._adapter_for_source(source)
|
||||
if _slack_adapter_for_progress is not None:
|
||||
try:
|
||||
_progress_reply_in_thread = bool(
|
||||
_slack_adapter_for_progress.config.extra.get(
|
||||
"reply_in_thread", True
|
||||
)
|
||||
# Relay lane: the adapter owns mode resolution (nested
|
||||
# platforms.relay.extra.slack subset with flat-key
|
||||
# fallback). Native lane: read the flat extra as before.
|
||||
_mode_fn = getattr(
|
||||
_slack_adapter_for_progress,
|
||||
"_effective_reply_in_thread",
|
||||
None,
|
||||
)
|
||||
if callable(_mode_fn):
|
||||
_progress_reply_in_thread = bool(_mode_fn())
|
||||
else:
|
||||
_progress_reply_in_thread = bool(
|
||||
_slack_adapter_for_progress.config.extra.get(
|
||||
"reply_in_thread", True
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_progress_reply_in_thread = True
|
||||
_progress_thread_id = _resolve_progress_thread_id(
|
||||
|
|
|
|||
403
tests/gateway/relay/test_relay_slack_dm_streaming.py
Normal file
403
tests/gateway/relay/test_relay_slack_dm_streaming.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""Slack relay: edit-based streaming of the reply must fire in a DM.
|
||||
|
||||
Reported symptom (live): agent responses stream progressively (edit-based) in a
|
||||
Slack THREAD but arrive FLAT (single message, no progressive edits) in a Slack
|
||||
DM/home over the relay.
|
||||
|
||||
Root cause: a DM turn's streaming reply is sent with
|
||||
``reply_to = <triggering message ts>`` (the stream consumer's
|
||||
``initial_reply_to_id`` — its edit anchor). The connector's slackRestSender maps
|
||||
a raw ``reply_to`` to a Slack ``thread_ts``, so a plain DM reply gets threaded
|
||||
UNDER the user's message instead of posting flat at the DM root, and a threaded
|
||||
first send loses the progressive edit streaming the user sees in a real thread.
|
||||
Native ``SlackAdapter._resolve_thread_ts`` already suppresses this synthetic DM
|
||||
thread anchor; the relay lane had no such disambiguation.
|
||||
|
||||
These are behaviour-contract tests: they assert how the outbound frame relates to
|
||||
the chat type + thread metadata (the invariant the connector depends on), not a
|
||||
snapshot. They drive the REAL ``RelayAdapter`` + ``GatewayStreamConsumer`` +
|
||||
``StubConnector`` end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.relay.adapter import RelayAdapter
|
||||
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
||||
from gateway.session import SessionSource
|
||||
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
||||
|
||||
from tests.gateway.relay.stub_connector import StubConnector
|
||||
|
||||
|
||||
def _slack_desc(**kw) -> CapabilityDescriptor:
|
||||
base = dict(
|
||||
contract_version=CONTRACT_VERSION,
|
||||
platform="slack",
|
||||
label="Slack",
|
||||
max_message_length=4000,
|
||||
supports_draft_streaming=False,
|
||||
supports_edit=True,
|
||||
supports_threads=True,
|
||||
markdown_dialect="mrkdwn",
|
||||
len_unit="chars",
|
||||
emoji="\U0001f4ac",
|
||||
platform_hint="",
|
||||
pii_safe=False,
|
||||
)
|
||||
base.update(kw)
|
||||
return CapabilityDescriptor(**base)
|
||||
|
||||
|
||||
def _wire(chat_id: str, chat_type: str, *, user_id="U1", scope_id=None):
|
||||
"""A RelayAdapter fronting Slack, with inbound scope captured for chat_id."""
|
||||
stub = StubConnector(_slack_desc())
|
||||
adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub)
|
||||
src = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=user_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
adapter._capture_scope(
|
||||
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
)
|
||||
return adapter, stub
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The pure disambiguation contract (RelayAdapter.send)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_reply_keeps_anchor_in_thread_per_message_mode():
|
||||
"""Default mode (reply_in_thread=True, thread-per-message): the triggering
|
||||
ts reply_to is the final reply's ONLY threading signal (base.py builds
|
||||
metadata from source.thread_id, which is None for a top-level DM) — it
|
||||
must be KEPT so the final message lands in the per-message thread with
|
||||
the progress bubbles (2026-07-27 mixed-placement report)."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
await adapter.send("D1", "the answer", reply_to="1700.0001")
|
||||
assert len(stub.sent) == 1
|
||||
frame = stub.sent[0]
|
||||
assert frame["op"] == "send"
|
||||
assert frame["reply_to"] == "1700.0001", (
|
||||
"thread-per-message: the triggering ts anchors the final reply"
|
||||
)
|
||||
# The connector's Slack sender threads on metadata.thread_id ONLY
|
||||
# (threadTs() never reads the frame's reply_to), so the surviving anchor
|
||||
# must be promoted into metadata for the send to actually thread.
|
||||
assert (frame["metadata"] or {}).get("thread_id") == "1700.0001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_reply_drops_synthetic_anchor_in_flat_mode():
|
||||
"""Flat mode (reply_in_thread=False): the synthetic self-anchor is dropped
|
||||
so the reply posts flat at the DM root (native _resolve_thread_ts parity)
|
||||
and no synthetic thread is invented (#18859)."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
await adapter.send("D1", "the answer", reply_to="1700.0001")
|
||||
frame = stub.sent[0]
|
||||
assert frame["reply_to"] is None
|
||||
assert "thread_id" not in (frame["metadata"] or {})
|
||||
assert "thread_ts" not in (frame["metadata"] or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_reply_with_real_thread_keeps_anchor():
|
||||
"""A DM turn that IS inside a real thread (metadata carries a distinct
|
||||
thread_id) must keep threading — the guard only drops the synthetic anchor."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
await adapter.send(
|
||||
"D1", "in thread", reply_to="1700.0002", metadata={"thread_id": "1699.9000"}
|
||||
)
|
||||
frame = stub.sent[0]
|
||||
assert frame["reply_to"] == "1700.0002"
|
||||
assert frame["metadata"]["thread_id"] == "1699.9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_channel_top_level_reply_keeps_autothread_anchor():
|
||||
"""A channel top-level reply carries thread_id (its own ts) in metadata when
|
||||
autoThread is on; the DM-only guard must not touch it."""
|
||||
adapter, stub = _wire("C1", "channel", scope_id="T1")
|
||||
await adapter.send(
|
||||
"C1", "channel reply", reply_to="1700.0003", metadata={"thread_id": "1700.0003"}
|
||||
)
|
||||
frame = stub.sent[0]
|
||||
assert frame["reply_to"] == "1700.0003"
|
||||
assert frame["metadata"]["thread_id"] == "1700.0003"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_slack_dm_reply_unchanged():
|
||||
"""The disambiguation is Slack-scoped: a non-Slack relay chat keeps reply_to
|
||||
(its connector owns its own threading semantics)."""
|
||||
stub = StubConnector(_slack_desc(platform="discord"))
|
||||
adapter = RelayAdapter(
|
||||
PlatformConfig(), _slack_desc(platform="discord"), transport=stub
|
||||
)
|
||||
src = SessionSource(
|
||||
platform=Platform.DISCORD, chat_id="dc1", chat_type="dm", user_id="U1"
|
||||
)
|
||||
adapter._capture_scope(
|
||||
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
)
|
||||
await adapter.send("dc1", "hi", reply_to="msg-9")
|
||||
assert stub.sent[0]["reply_to"] == "msg-9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: the stream consumer keeps edit-streaming in a DM
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _drive_stream(adapter, chat_id, *, metadata, initial_reply_to_id, chat_type):
|
||||
cfg = StreamConsumerConfig(
|
||||
edit_interval=0.0,
|
||||
buffer_threshold=1,
|
||||
transport="edit",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id=chat_id,
|
||||
config=cfg,
|
||||
metadata=metadata,
|
||||
initial_reply_to_id=initial_reply_to_id,
|
||||
)
|
||||
# Feed progressive deltas, then finalize — mirrors the live delta callback.
|
||||
for chunk in ("Hel", "lo ", "world", ". Done."):
|
||||
consumer.on_delta(chunk)
|
||||
consumer.finish()
|
||||
await consumer.run()
|
||||
return consumer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_stream_consumer_edits_own_ts_not_flat():
|
||||
"""A Slack DM turn (chat_type='dm', no thread, metadata None) still builds a
|
||||
stream consumer that keeps edit support and emits progressive EDITs of the
|
||||
reply message — the flat-DM regression contract.
|
||||
|
||||
The connector returns a real message_id for the flat first send, so edit
|
||||
support must stay on and at least one edit op must be emitted (progressive
|
||||
streaming), identical to a thread. No synthetic thread is created.
|
||||
|
||||
Runs in EXPLICIT flat mode (reply_in_thread=False) — that is the mode this
|
||||
contract belongs to; the default thread-per-message path is covered by
|
||||
test_slack_dm_stream_consumer_threads_in_thread_per_message_mode."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
consumer = await _drive_stream(
|
||||
adapter,
|
||||
"D1",
|
||||
metadata=None, # DM: _status_thread_metadata is None in run.py
|
||||
initial_reply_to_id="1700.0001", # the triggering message ts
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
ops = [f["op"] for f in stub.sent]
|
||||
# First a flat send; edit support stays on so progressive edits CAN flow
|
||||
# (exact intermediate-frame timing is covered by the stream_consumer unit
|
||||
# suite — here we assert the DM regression contract: streaming is not
|
||||
# self-disabled and every edit targets the reply's own ts).
|
||||
assert ops[0] == "send"
|
||||
# Edit support survived: message_id set, not the __no_edit__ sentinel.
|
||||
assert consumer.message_id and consumer.message_id != "__no_edit__"
|
||||
assert consumer._edit_supported is True
|
||||
|
||||
first_send = stub.sent[0]
|
||||
# The reply posts FLAT at the DM root — no synthetic thread anchor.
|
||||
assert first_send["reply_to"] is None
|
||||
assert "thread_id" not in (first_send["metadata"] or {})
|
||||
assert "thread_ts" not in (first_send["metadata"] or {})
|
||||
# reply_to_message_id (the mirrored self-anchor) is stripped too.
|
||||
assert "reply_to_message_id" not in (first_send["metadata"] or {})
|
||||
|
||||
# Any edits that flowed target the same first-send ts (editing its own
|
||||
# message), never a synthetic thread.
|
||||
edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"}
|
||||
assert edit_ids <= {stub.next_send_result["message_id"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_thread_stream_consumer_still_threads_and_streams():
|
||||
"""Regression guard: a Slack THREAD turn keeps its real thread_id AND streams
|
||||
(the DM fix must not change the thread path)."""
|
||||
adapter, stub = _wire("C1", "channel", scope_id="T1")
|
||||
consumer = await _drive_stream(
|
||||
adapter,
|
||||
"C1",
|
||||
metadata={"thread_id": "1699.9000"},
|
||||
initial_reply_to_id="1700.0002",
|
||||
chat_type="channel",
|
||||
)
|
||||
ops = [f["op"] for f in stub.sent]
|
||||
assert ops[0] == "send"
|
||||
assert consumer._edit_supported is True
|
||||
first_send = stub.sent[0]
|
||||
# Thread preserved: the real thread_id rides along and reply_to is kept.
|
||||
assert first_send["metadata"]["thread_id"] == "1699.9000"
|
||||
assert first_send["reply_to"] == "1700.0002"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_stream_consumer_threads_in_thread_per_message_mode():
|
||||
"""Default mode: the DM stream's first send keeps the triggering-ts anchor
|
||||
so the streamed final reply lands in the per-message thread; edits still
|
||||
target the reply's own ts."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
consumer = await _drive_stream(
|
||||
adapter,
|
||||
"D1",
|
||||
metadata=None,
|
||||
initial_reply_to_id="1700.0001",
|
||||
chat_type="dm",
|
||||
)
|
||||
first_send = stub.sent[0]
|
||||
assert first_send["op"] == "send"
|
||||
assert first_send["reply_to"] == "1700.0001"
|
||||
assert consumer.message_id and consumer.message_id != "__no_edit__"
|
||||
edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"}
|
||||
assert edit_ids <= {stub.next_send_result["message_id"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The media lane obeys the SAME thread-anchor contract as the text lane.
|
||||
#
|
||||
# send() and _send_media() both egress through the connector's Slack sender,
|
||||
# so an anchor resolved on only one of them threads an image under the user's
|
||||
# DM message in flat mode, and loses the per-message thread in thread mode
|
||||
# (threadTs() reads metadata only). Both lanes route through
|
||||
# _apply_slack_thread_anchor; these pin that they stay in agreement.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _media_wire(chat_id: str, chat_type: str):
|
||||
"""Like _wire, but the descriptor advertises the send_media op."""
|
||||
desc = _slack_desc(supported_ops=("send", "edit", "typing", "send_media"))
|
||||
stub = StubConnector(desc)
|
||||
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
|
||||
src = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id="U1",
|
||||
)
|
||||
adapter._capture_scope(
|
||||
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
)
|
||||
return adapter, stub
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_media_keeps_and_promotes_anchor_in_thread_mode():
|
||||
"""Thread-per-message: an image must land in the per-message thread. The
|
||||
connector threads on metadata.thread_id only, so the surviving anchor has
|
||||
to be promoted there — a bare reply_to would post to the home channel."""
|
||||
adapter, stub = _media_wire("D1", "dm")
|
||||
await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001")
|
||||
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
|
||||
assert frame["reply_to"] == "1700.0001"
|
||||
assert (frame["metadata"] or {}).get("thread_id") == "1700.0001", (
|
||||
"media frame must carry the anchor where the connector reads it"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_dm_media_drops_synthetic_anchor_in_flat_mode():
|
||||
"""Flat mode: the media frame drops the synthetic self-anchor exactly as
|
||||
the text lane does, so the image posts flat at the DM root instead of
|
||||
threading under the user's message, and invents no thread (#18859)."""
|
||||
adapter, stub = _media_wire("D1", "dm")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001")
|
||||
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
|
||||
assert frame["reply_to"] is None
|
||||
assert "thread_id" not in (frame["metadata"] or {})
|
||||
assert "thread_ts" not in (frame["metadata"] or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_channel_media_anchor_untouched():
|
||||
"""Non-DM chats are outside the synthetic-anchor rule: a channel media
|
||||
send keeps its reply_to unchanged."""
|
||||
adapter, stub = _media_wire("C1", "channel")
|
||||
await adapter.send_image("C1", "https://example.com/x.png", reply_to="1700.0009")
|
||||
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
|
||||
assert frame["reply_to"] == "1700.0009"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_caller_metadata_not_mutated():
|
||||
"""The anchor promotion must not leak into the caller's dict — media
|
||||
helpers are called in loops with a shared metadata mapping."""
|
||||
adapter, stub = _media_wire("D1", "dm")
|
||||
caller_md = {"user_id": "U1"}
|
||||
await adapter.send_image(
|
||||
"D1", "https://example.com/x.png", reply_to="1700.0001", metadata=caller_md
|
||||
)
|
||||
assert caller_md == {"user_id": "U1"}, "caller metadata was mutated in place"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operator flags coerce exactly as the native Slack adapter's do.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
(False, False),
|
||||
("false", False),
|
||||
("False", False),
|
||||
(" no ", False),
|
||||
("off", False),
|
||||
("0", False),
|
||||
(True, True),
|
||||
("true", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("1", True),
|
||||
],
|
||||
)
|
||||
def test_relay_slack_flags_coerce_like_native(raw, expected):
|
||||
"""A YAML-quoted "false" must turn these knobs OFF, matching native's
|
||||
str().strip().lower() predicate. A bare bool() would read any non-empty
|
||||
string as True and silently ignore the operator's off switch."""
|
||||
adapter, _stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {
|
||||
"slack": {
|
||||
"reply_in_thread": raw,
|
||||
"dm_top_level_threads_as_sessions": raw,
|
||||
}
|
||||
}
|
||||
assert adapter._effective_reply_in_thread() is expected
|
||||
assert adapter._dm_top_level_threads_as_sessions() is expected
|
||||
|
||||
|
||||
def test_relay_slack_flags_default_true_when_absent():
|
||||
"""Both knobs default ON when the operator sets nothing."""
|
||||
adapter, _stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {}
|
||||
assert adapter._effective_reply_in_thread() is True
|
||||
assert adapter._dm_top_level_threads_as_sessions() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The status clear targets the same thread the heartbeat set.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_and_clear_share_one_status_anchor():
|
||||
"""send_typing and stop_typing resolve the anchor through one helper: a
|
||||
clear that no-ops threadless leaves the status line stuck until Slack's
|
||||
own timeout."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
adapter._last_inbound_ts_by_chat["D1"] = "1700.0001"
|
||||
await adapter.send_typing("D1")
|
||||
await adapter.stop_typing("D1")
|
||||
typing_frames = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert len(typing_frames) == 2
|
||||
anchors = [(f["metadata"] or {}).get("thread_id") for f in typing_frames]
|
||||
assert anchors == ["1700.0001", "1700.0001"], (
|
||||
"the clear must target the thread the heartbeat set"
|
||||
)
|
||||
486
tests/gateway/relay/test_relay_slack_prompt_dm_root.py
Normal file
486
tests/gateway/relay/test_relay_slack_prompt_dm_root.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
"""Slack relay: interactive prompts follow the turn's thread stamp.
|
||||
|
||||
The threading MODE (flat DM vs thread-per-message) is decided in exactly ONE
|
||||
place: run.py's ``_resolve_progress_thread_id``, which reads
|
||||
``platforms.slack.extra.reply_in_thread`` and encodes the verdict into the
|
||||
outbound ``metadata`` stamp:
|
||||
|
||||
* flat mode -> the synthetic self-anchor is suppressed in run.py, so prompt
|
||||
metadata arrives with NO ``thread_id`` and the card posts at the DM root;
|
||||
* thread-per-message (default) -> ``metadata.thread_id`` is stamped for the
|
||||
whole turn; on the FIRST turn it legitimately equals the triggering
|
||||
message's ts (the synthetic root IS the thread).
|
||||
|
||||
The prompt lane must TRUST that stamp, like ``_resolve_reply_to_for_send``
|
||||
does. Re-deriving the mode here (the old unconditional
|
||||
``thread_id == message_id`` strip) exiled the approval card and its
|
||||
resolved-state swap to the DM root while progress bubbles honoured the thread
|
||||
(the 2026-07-27 mixed-placement report).
|
||||
|
||||
These are behaviour-contract tests: they assert how the outbound ``prompt``
|
||||
frame relates to the inherited thread metadata (the invariant the connector
|
||||
depends on), not a snapshot. They drive the REAL ``RelayAdapter`` +
|
||||
``StubConnector`` end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.relay.adapter import RelayAdapter
|
||||
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
||||
from gateway.session import SessionSource
|
||||
|
||||
from tests.gateway.relay.stub_connector import StubConnector
|
||||
|
||||
FULL_OPS = ("send", "edit", "typing", "get_chat_info", "send_media", "prompt", "react")
|
||||
|
||||
|
||||
def _slack_desc(**kw) -> CapabilityDescriptor:
|
||||
base = dict(
|
||||
contract_version=CONTRACT_VERSION,
|
||||
platform="slack",
|
||||
label="Slack",
|
||||
max_message_length=4000,
|
||||
supports_draft_streaming=False,
|
||||
supports_edit=True,
|
||||
supports_threads=True,
|
||||
markdown_dialect="mrkdwn",
|
||||
len_unit="chars",
|
||||
supported_ops=FULL_OPS,
|
||||
)
|
||||
base.update(kw)
|
||||
return CapabilityDescriptor(**base)
|
||||
|
||||
|
||||
def _wire(
|
||||
chat_id: str,
|
||||
chat_type: str,
|
||||
*,
|
||||
user_id="U1",
|
||||
scope_id=None,
|
||||
platform=Platform.SLACK,
|
||||
):
|
||||
"""A RelayAdapter fronting Slack, with inbound scope + chat_type captured."""
|
||||
stub = StubConnector(_slack_desc())
|
||||
adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub)
|
||||
src = SessionSource(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=user_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
adapter._capture_scope(
|
||||
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
)
|
||||
return adapter, stub
|
||||
|
||||
|
||||
def _last_prompt(stub) -> dict:
|
||||
prompts = [f for f in stub.sent if f["op"] == "prompt"]
|
||||
assert prompts, "expected a prompt op on the wire"
|
||||
return prompts[-1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flat mode: run.py stamps NO thread_id -> the card posts at the DM root.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_flat_mode_posts_at_dm_root():
|
||||
"""Flat-DM turn (reply_in_thread=false): run.py suppressed the synthetic
|
||||
anchor upstream, so prompt metadata has no thread_id and none appears on
|
||||
the wire — the card posts at the DM root."""
|
||||
adapter, stub = _wire("D1", "dm", scope_id="T1")
|
||||
md = {"message_id": "1700000000.000100", "scope_id": "T1"}
|
||||
result = await adapter.send_exec_approval(
|
||||
"D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
|
||||
)
|
||||
assert result.success is True
|
||||
frame = _last_prompt(stub)
|
||||
meta = frame["metadata"] or {}
|
||||
assert "thread_id" not in meta
|
||||
assert "thread_ts" not in meta
|
||||
# reply_to on the outbound action stays unset — a root-level post.
|
||||
assert frame["reply_to"] is None
|
||||
# Tenant scope is preserved untouched (egress routing must not break).
|
||||
assert meta.get("scope_id") == "T1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-per-message mode, end-to-end placement contract: run.py stamps the
|
||||
# turn's thread (first turn: the triggering message's own ts) and the adapter
|
||||
# forwards prompt metadata UNTOUCHED — no re-derivation, no strip. Mixed
|
||||
# placement (progress threaded, card at root) was the 2026-07-27 regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_forwards_run_py_thread_stamp_untouched():
|
||||
"""The adapter must forward run.py's thread stamp verbatim: the approval
|
||||
card posts INTO the stamped thread. Any adapter-side re-derivation or
|
||||
strip exiled the card to the home channel (2026-07-27 report)."""
|
||||
adapter, stub = _wire("D1", "dm", scope_id="T1")
|
||||
md = {
|
||||
"thread_id": "1700000000.000100",
|
||||
"message_id": "1700000000.000100",
|
||||
"scope_id": "T1",
|
||||
}
|
||||
result = await adapter.send_exec_approval(
|
||||
"D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
|
||||
)
|
||||
assert result.success is True
|
||||
frame = _last_prompt(stub)
|
||||
meta = frame["metadata"] or {}
|
||||
assert meta.get("thread_id") == "1700000000.000100", (
|
||||
"first-turn self-anchor is the thread root; the prompt must honour it"
|
||||
)
|
||||
assert meta.get("scope_id") == "T1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_forwards_run_py_thread_stamp_untouched():
|
||||
adapter, stub = _wire("D1", "dm", scope_id="T1")
|
||||
md = {
|
||||
"thread_id": "1700000000.000200",
|
||||
"message_id": "1700000000.000200",
|
||||
"scope_id": "T1",
|
||||
}
|
||||
result = await adapter.send_clarify(
|
||||
"D1", "Which env?", ["prod", "staging"], "cl-1", "sess:1", metadata=md
|
||||
)
|
||||
assert result.success is True
|
||||
frame = _last_prompt(stub)
|
||||
meta = frame["metadata"] or {}
|
||||
assert meta.get("thread_id") == "1700000000.000200"
|
||||
assert meta.get("scope_id") == "T1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_confirm_forwards_run_py_thread_stamp_untouched():
|
||||
"""The forward-untouched rule covers every prompt surface (single
|
||||
_send_prompt choke point)."""
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"}
|
||||
await adapter.send_slash_confirm(
|
||||
"D1", "Reload MCP", "invalidates cache", "s", "cf-1", metadata=md
|
||||
)
|
||||
frame = _last_prompt(stub)
|
||||
assert (frame["metadata"] or {}).get("thread_id") == "1700000000.000300"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression guards: a REAL thread and non-DM / non-Slack chats are untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_in_real_thread_keeps_thread_id():
|
||||
"""A DM prompt raised inside a REAL thread (thread_id distinct from the
|
||||
triggering message ts) stays in that thread."""
|
||||
adapter, stub = _wire("D1", "dm", scope_id="T1")
|
||||
md = {
|
||||
"thread_id": "1699000000.999000",
|
||||
"message_id": "1700000000.000100",
|
||||
"scope_id": "T1",
|
||||
}
|
||||
await adapter.send_exec_approval("D1", "cmd", "s", metadata=md)
|
||||
frame = _last_prompt(stub)
|
||||
assert frame["metadata"]["thread_id"] == "1699000000.999000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_approval_keeps_thread_id():
|
||||
"""A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread)."""
|
||||
adapter, stub = _wire("C1", "channel", scope_id="T1")
|
||||
md = {
|
||||
"thread_id": "1700000000.000400",
|
||||
"message_id": "1700000000.000400",
|
||||
"scope_id": "T1",
|
||||
}
|
||||
await adapter.send_exec_approval("C1", "cmd", "s", metadata=md)
|
||||
frame = _last_prompt(stub)
|
||||
assert frame["metadata"]["thread_id"] == "1700000000.000400"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_slack_dm_approval_keeps_thread_id():
|
||||
"""A non-Slack relay DM keeps thread_id (its connector owns its own
|
||||
threading semantics)."""
|
||||
adapter, stub = _wire("dc1", "dm", platform=Platform.DISCORD)
|
||||
md = {"thread_id": "9000", "message_id": "9000"}
|
||||
await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md)
|
||||
frame = _last_prompt(stub)
|
||||
assert frame["metadata"]["thread_id"] == "9000"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich status: the relay advertises Slack's text status line and carries
|
||||
# the live per-tool phrase on the typing frame (native set_status_text parity).
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_relay_advertises_status_text():
|
||||
adapter, _stub = _wire("D1", "dm")
|
||||
assert adapter.supports_status_text is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_slack_relay_does_not_advertise_status_text():
|
||||
stub = StubConnector(_slack_desc(platform="discord"))
|
||||
adapter = RelayAdapter(
|
||||
PlatformConfig(), _slack_desc(platform="discord"), transport=stub
|
||||
)
|
||||
assert adapter.supports_status_text is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_carries_live_status_phrase():
|
||||
"""set_status_text() -> the next typing frame carries the phrase as
|
||||
content; clearing it (None) reverts to a content-less heartbeat frame
|
||||
(never an empty string, which is Slack's explicit clear)."""
|
||||
adapter, stub = _wire("D1", "dm", scope_id="T1")
|
||||
adapter.set_status_text("D1", "is running pytest…")
|
||||
await adapter.send_typing("D1", metadata={"scope_id": "T1"})
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert typing and typing[-1].get("content") == "is running pytest…"
|
||||
|
||||
adapter.set_status_text("D1", None)
|
||||
await adapter.send_typing("D1", metadata={"scope_id": "T1"})
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert "content" not in typing[-1], (
|
||||
"cleared phrase must omit content (empty string means CLEAR on Slack)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status thread anchor: typing frames synthesize the per-message thread
|
||||
# root in thread-per-message mode (the status line is thread-only on Slack).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _wire_with_ts(chat_id, chat_type, message_id, **kw):
|
||||
adapter, stub = _wire(chat_id, chat_type, **kw)
|
||||
src = SessionSource(
|
||||
platform=Platform.SLACK, chat_id=chat_id, chat_type=chat_type,
|
||||
user_id="U1", scope_id=kw.get("scope_id"),
|
||||
)
|
||||
ev = MessageEvent(
|
||||
text="hi", source=src, message_type=MessageType.TEXT, message_id=message_id
|
||||
)
|
||||
adapter._capture_scope(ev)
|
||||
return adapter, stub
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_synthesizes_thread_anchor_in_thread_mode():
|
||||
"""Top-level DM turn, thread-per-message mode: the typing frame gains the
|
||||
triggering ts as thread_id so the connector's setStatus targets the
|
||||
per-message thread instead of no-oping threadless."""
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
await adapter.send_typing("D1", metadata=None)
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default():
|
||||
"""Flat-DM liveliness: the STATUS still anchors to the triggering ts
|
||||
(renders in the footer space, no message artifact) while replies stay
|
||||
flat — the send lane strips its anchors, so placement cannot inherit this."""
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
await adapter.send_typing("D1", metadata=None)
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_anchors_unconditionally_in_both_modes():
|
||||
"""Liveliness is not a preference: the status anchors whenever an inbound
|
||||
ts exists, regardless of reply_in_thread. Placement safety comes from the
|
||||
send-side anchor strip, not from suppressing the status."""
|
||||
for extra in ({}, {"slack": {"reply_in_thread": False}}):
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
adapter.config.extra = extra
|
||||
await adapter.send_typing("D1", metadata=None)
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flat_mode_sends_stay_flat_with_status_anchor_active():
|
||||
"""The liveliness anchor must NOT leak into reply placement: sends in
|
||||
flat mode still strip the synthetic anchor (send-lane contract)."""
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
await adapter.send_typing("D1", metadata=None)
|
||||
await adapter.send("D1", "the answer", reply_to="1700.0042")
|
||||
frame = [f for f in stub.sent if f["op"] == "send"][-1]
|
||||
assert frame["reply_to"] is None
|
||||
assert "thread_id" not in (frame["metadata"] or {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_honours_real_thread_anchor():
|
||||
"""Metadata that already names a thread wins over the synthetic cache."""
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
await adapter.send_typing("D1", metadata={"thread_id": "1699.9000"})
|
||||
typing = [f for f in stub.sent if f["op"] == "typing"]
|
||||
assert typing[-1]["metadata"]["thread_id"] == "1699.9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_clear_targets_same_synthesized_thread():
|
||||
"""The clear frame targets the same synthesized thread as the heartbeat
|
||||
(else the status line sticks)."""
|
||||
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
|
||||
await adapter.send_typing("D1", metadata=None)
|
||||
await adapter.stop_typing("D1", metadata=None)
|
||||
clears = [
|
||||
f for f in stub.sent if f["op"] == "typing" and f.get("content") == ""
|
||||
]
|
||||
assert clears and clears[-1]["metadata"].get("thread_id") == "1700.0042"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session keying: a top-level Slack DM message gets its own ts stamped as
|
||||
# source.thread_id (native inbound parity) so each message keys a FRESH
|
||||
# session in thread-per-message mode; flat mode and real threads untouched.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _inbound_event(chat_id="D1", message_id="1700.0100", thread_id=None):
|
||||
src = SessionSource(
|
||||
platform=Platform.SLACK, chat_id=chat_id, chat_type="dm",
|
||||
user_id="U1", scope_id="T1", thread_id=thread_id,
|
||||
)
|
||||
return MessageEvent(
|
||||
text="hi", source=src, message_type=MessageType.TEXT,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def test_top_level_dm_gets_session_thread_stamp():
|
||||
adapter, _ = _wire("D1", "dm")
|
||||
ev = _inbound_event(message_id="1700.0100")
|
||||
adapter._stamp_slack_session_thread(ev)
|
||||
assert ev.source.thread_id == "1700.0100"
|
||||
|
||||
|
||||
def test_two_top_level_messages_key_distinct_sessions():
|
||||
from gateway.session import build_session_key
|
||||
adapter, _ = _wire("D1", "dm")
|
||||
e1 = _inbound_event(message_id="1700.0100")
|
||||
e2 = _inbound_event(message_id="1700.0200")
|
||||
adapter._stamp_slack_session_thread(e1)
|
||||
adapter._stamp_slack_session_thread(e2)
|
||||
k1 = build_session_key(e1.source)
|
||||
k2 = build_session_key(e2.source)
|
||||
assert k1 != k2, "each top-level message must be its own session"
|
||||
|
||||
|
||||
def test_real_thread_reply_keeps_its_thread_session():
|
||||
adapter, _ = _wire("D1", "dm")
|
||||
ev = _inbound_event(message_id="1700.0300", thread_id="1700.0100")
|
||||
adapter._stamp_slack_session_thread(ev)
|
||||
assert ev.source.thread_id == "1700.0100", (
|
||||
"an in-thread reply must keep resolving to its thread's session"
|
||||
)
|
||||
|
||||
|
||||
def test_flat_mode_keeps_shared_dm_session():
|
||||
adapter, _ = _wire("D1", "dm")
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
ev = _inbound_event(message_id="1700.0400")
|
||||
adapter._stamp_slack_session_thread(ev)
|
||||
assert ev.source.thread_id is None, (
|
||||
"flat mode: shared rolling DM session (steer/queue) is intended UX"
|
||||
)
|
||||
|
||||
|
||||
def test_nested_relay_slack_config_subset_wins():
|
||||
"""Enterprise knob shape: platforms.relay.extra.slack.reply_in_thread."""
|
||||
adapter, _ = _wire("D1", "dm")
|
||||
adapter.config.extra = {"slack": {"reply_in_thread": False}}
|
||||
assert adapter._effective_reply_in_thread() is False
|
||||
adapter.config.extra = {"slack": {"reply_in_thread": True}}
|
||||
assert adapter._effective_reply_in_thread() is True
|
||||
# Legacy flat key still honoured when no nested object exists.
|
||||
adapter.config.extra = {"reply_in_thread": False}
|
||||
assert adapter._effective_reply_in_thread() is False
|
||||
# Default: thread-per-message.
|
||||
adapter.config.extra = {}
|
||||
assert adapter._effective_reply_in_thread() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-module boundary pin (review 2026-07-28): the adapter deliberately has
|
||||
# NO prompt-side strip — flat-mode placement depends entirely on run.py's
|
||||
# _resolve_progress_thread_id suppressing the synthetic self-anchor upstream.
|
||||
# If that suppression regresses, prompt cards silently thread again. These
|
||||
# tests pin the boundary in BOTH modes so the coupling is load-bearing.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_run_py_suppresses_self_anchor_in_flat_mode():
|
||||
from gateway.run import _resolve_progress_thread_id
|
||||
|
||||
# Flat mode + synthetic self-anchor (thread_id == own message id) => None:
|
||||
# prompt/progress metadata arrives at the adapter with NO thread anchor.
|
||||
assert (
|
||||
_resolve_progress_thread_id(
|
||||
"slack", "1700.001", "1700.001", reply_in_thread=False
|
||||
)
|
||||
is None
|
||||
)
|
||||
# Flat mode + REAL thread (ids differ) => the real thread survives.
|
||||
assert (
|
||||
_resolve_progress_thread_id(
|
||||
"slack", "1699.000", "1700.001", reply_in_thread=False
|
||||
)
|
||||
== "1699.000"
|
||||
)
|
||||
|
||||
|
||||
def test_run_py_keeps_self_anchor_in_thread_mode():
|
||||
from gateway.run import _resolve_progress_thread_id
|
||||
|
||||
# Thread-per-message mode: the first-turn self-anchor IS the thread root
|
||||
# and must flow through to the adapter unchanged.
|
||||
assert (
|
||||
_resolve_progress_thread_id(
|
||||
"slack", "1700.001", "1700.001", reply_in_thread=True
|
||||
)
|
||||
== "1700.001"
|
||||
)
|
||||
# No source thread at all: Slack synthesizes the root from the message id.
|
||||
assert (
|
||||
_resolve_progress_thread_id("slack", None, "1700.001", reply_in_thread=True)
|
||||
== "1700.001"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native parity escape hatch: platforms.relay.extra.slack.
|
||||
# dm_top_level_threads_as_sessions=false keeps threaded replies but ONE
|
||||
# rolling DM session (mirrors native SlackAdapter._dm_top_level_threads_as_sessions).
|
||||
# Without the knob, reply_in_thread alone couples placement AND session
|
||||
# keying — a posture native operators can express and relay ones could not.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_stamp_opt_out_keeps_rolling_dm_session():
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {
|
||||
"slack": {
|
||||
"reply_in_thread": True,
|
||||
"dm_top_level_threads_as_sessions": False,
|
||||
}
|
||||
}
|
||||
event = _inbound_event("D1", message_id="1700.0001", thread_id=None)
|
||||
adapter._stamp_slack_session_thread(event)
|
||||
assert getattr(event.source, "thread_id", None) is None, (
|
||||
"opt-out: top-level DM must NOT be stamped — one rolling session"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_stamp_default_remains_per_message():
|
||||
adapter, stub = _wire("D1", "dm")
|
||||
adapter.config.extra = {"slack": {"reply_in_thread": True}}
|
||||
event = _inbound_event("D1", message_id="1700.0002", thread_id=None)
|
||||
adapter._stamp_slack_session_thread(event)
|
||||
assert getattr(event.source, "thread_id", None) == "1700.0002", (
|
||||
"default (native parity): per-message sessions stay on"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue