mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
feat(relay): Phase 4 thread lifecycle — handoff threads, semantic renames, reply_to context, hello command manifest (#71624)
- RelayAdapter.create_handoff_thread → one op-gated thread_create op
(Discord channel thread / Telegram forum topic / Slack named seed root);
None fallback contract preserved for the handoff watcher.
- RelayAdapter.rename_thread → thread_rename with only_if_current_name
no-clobber guard on the wire (connector enforces; Telegram guarded
renames fail safe). The native semantic-rename lane
(_is_discord_auto_thread_lane) lights over the relay via the
connector-stamped auto_thread_created/auto_thread_initial_name markers
parsed onto SessionSource in _event_from_wire.
- reply_to {text, author, is_own} wire parse onto the SAME MessageEvent
reply-context fields native adapters populate.
- gateway/relay/command_manifest.py: the gateway-declared slash-command
manifest (native Discord tree mirror) sent on the DISCORD hello; the
connector reconciles Discord's global registration (additive field,
older connectors ignore).
- Contract doc §OutboundAction ops + Phase 4 semantics sections.
- 12 new tests (tests/gateway/relay/test_relay_threads.py); relay suite
266 passed.
This commit is contained in:
parent
1161cc0b53
commit
40eebc7d70
5 changed files with 620 additions and 1 deletions
|
|
@ -397,6 +397,8 @@ The gateway calls the transport with action dicts. Source of truth:
|
|||
| `send_media` | `chat_id`, `media_kind`, `source_url`, `content?` (caption), `filename?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
|
||||
| `prompt` | `chat_id`, `prompt_kind`, `prompt_id`, `content` (the question), `options[]{id,label,style?}`, `timeout_s?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
|
||||
| `react` | `chat_id`, `message_id`, `emoji`, `remove?`, `metadata?` | `{success: bool, error?}` |
|
||||
| `thread_create` | `chat_id` (parent), `thread_name`, `message_id?` (anchor), `metadata?` | `{success: bool, thread_id?, error?}` |
|
||||
| `thread_rename` | `chat_id` (parent), `message_id` (the THREAD id), `thread_name`, `only_if_current_name?`, `metadata?` | `{success: bool, error?}` |
|
||||
|
||||
`get_chat_info(chat_id)` is a separate proxied call returning at least
|
||||
`{name, type}`.
|
||||
|
|
@ -478,6 +480,48 @@ treats reactions as cosmetic). WhatsApp sends a reaction message (empty
|
|||
emoji = remove). Reactions are best-effort by contract: a `react` failure
|
||||
must never fail a turn.
|
||||
|
||||
**`thread_create` / `thread_rename` (Phase 4 thread lifecycle).** One
|
||||
platform-abstract pair covers handoff threads, Telegram DM/forum topics, and
|
||||
LLM-title semantic renames. `thread_create`: Discord posts a channel thread
|
||||
(type 11) or a message-anchored thread when `message_id` is set; Telegram
|
||||
`createForumTopic` (topic id returned); Slack posts a NAMED seed root
|
||||
message and returns its `ts` (threads there are message-anchored — an
|
||||
explicit `message_id` anchor is echoed back verbatim). The created id rides
|
||||
`SendResult.thread_id`. `thread_rename`: Discord PATCHes the thread channel;
|
||||
Telegram `editForumTopic`. The **`only_if_current_name` no-clobber guard**
|
||||
is the native adapters' human-rename-wins semantics, enforced
|
||||
CONNECTOR-side: Discord reads the current name first and no-ops (structured
|
||||
`success:false`) on mismatch; Telegram has no topic-name read, so a GUARDED
|
||||
rename is unsatisfiable and fails safe (unguarded renames proceed). Slack
|
||||
does not advertise `thread_rename` (a root message's text is content, not a
|
||||
name). WhatsApp advertises neither (no threads).
|
||||
|
||||
**Auto-thread markers + gateway-declared command manifest (Phase 4
|
||||
inbound/handshake).** When the connector's auto-thread egress policy creates
|
||||
a Discord thread, later inbound events from that thread carry
|
||||
`source.auto_thread_created: true` + `source.auto_thread_initial_name` — the
|
||||
connector-observed evidence that lights the gateway's semantic-rename lane
|
||||
(the LLM session title renames the thread via a GUARDED `thread_rename`;
|
||||
per-instance memory, so in an N>1 fleet a miss simply never lights the
|
||||
lane). The gateway may also declare its slash-command set on the Discord
|
||||
`hello` frame (`command_manifest: [{name, description, options?}]`); the
|
||||
connector reconciles Discord's GLOBAL application-command registration
|
||||
against it (GET → diff → bulk PUT overwrite; idempotent, debounced,
|
||||
best-effort — a registration failure never affects the handshake). Commands
|
||||
still dispatch through the passthrough plane as before; the manifest only
|
||||
keeps Discord's registry in sync with what the gateway's dispatcher handles.
|
||||
|
||||
**Inbound `reply_to` enrichment (Phase 4).** A platform reply may carry
|
||||
`reply_to: {text?, author?, is_own?}` alongside `reply_to_message_id` — what
|
||||
the user QUOTED, populated only from data the connector already had in hand
|
||||
(Discord's inline `referenced_message`, Telegram's inline
|
||||
`reply_to_message`, WhatsApp `context.from` + a bounded per-instance
|
||||
inbound-text cache for the text leg). Absent fields mean the platform didn't
|
||||
carry the data — never triggers an extra platform API call. `is_own` = the
|
||||
quoted message was authored by the fronted bot (same evidence as the
|
||||
`is_reply_to_bot` relevance marker). The gateway maps these onto the same
|
||||
MessageEvent reply-context fields native adapters populate.
|
||||
|
||||
**`typing` `content?` (Slack status clear).** A `typing` frame normally omits
|
||||
`content` — the connector renders its platform's active indicator ("is
|
||||
typing…" Assistant status on Slack, one-shot typing elsewhere). An **empty
|
||||
|
|
|
|||
|
|
@ -1522,3 +1522,98 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
await self._react(str(chat_id), str(message_id), "✅")
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self._react(str(chat_id), str(message_id), "❌")
|
||||
|
||||
# ── Phase 4 thread lifecycle ──────────────────────────────────────────
|
||||
|
||||
async def create_handoff_thread(
|
||||
self,
|
||||
parent_chat_id: str,
|
||||
name: str,
|
||||
) -> Optional[str]:
|
||||
"""Create a thread/topic under ``parent_chat_id`` via the connector.
|
||||
|
||||
One `thread_create` op covers Discord (channel thread), Telegram
|
||||
(forum topic), and Slack (named seed root message — threads there are
|
||||
message-anchored). Op-gated on the descriptor advertising
|
||||
`thread_create`; None on any failure/unavailability so the handoff
|
||||
watcher falls back to the parent channel — the same contract as the
|
||||
native adapters' create_handoff_thread.
|
||||
"""
|
||||
if self._transport is None or not self.descriptor.supports_op("thread_create"):
|
||||
return None
|
||||
thread_name = (str(name or "").strip() or "handoff")[:100]
|
||||
try:
|
||||
result = await self._transport.send_outbound(
|
||||
{
|
||||
"op": "thread_create",
|
||||
"chat_id": str(parent_chat_id),
|
||||
"thread_name": thread_name,
|
||||
"metadata": self._with_scope(str(parent_chat_id), None),
|
||||
},
|
||||
platform=self._platform_by_chat.get(str(parent_chat_id)),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - handoff falls back to the parent channel
|
||||
logger.debug("relay thread_create transport failure", exc_info=True)
|
||||
return None
|
||||
if not result.get("success"):
|
||||
logger.info(
|
||||
"relay thread_create declined for %s: %s",
|
||||
parent_chat_id,
|
||||
result.get("error"),
|
||||
)
|
||||
return None
|
||||
thread_id = result.get("thread_id") or result.get("message_id")
|
||||
return str(thread_id) if thread_id else None
|
||||
|
||||
async def rename_thread(
|
||||
self,
|
||||
thread_id: str,
|
||||
name: str,
|
||||
*,
|
||||
only_if_current_name: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Best-effort thread rename via the connector's `thread_rename` op.
|
||||
|
||||
The relay sibling of the native Discord adapter's rename_thread —
|
||||
called by the SAME semantic-rename lane (run.py
|
||||
_rename_discord_auto_thread_for_session_title), which fires only for
|
||||
sources carrying the connector-stamped auto-thread markers.
|
||||
``only_if_current_name`` crosses the wire; the CONNECTOR enforces the
|
||||
no-clobber guard (it owns the platform read), failing safe on
|
||||
platforms that can't read the current name. ``parent_chat_id`` is
|
||||
the containing chat where the caller knows it (Telegram needs it);
|
||||
defaults to the thread id itself (Discord ignores chat_id).
|
||||
"""
|
||||
if self._transport is None or not self.descriptor.supports_op("thread_rename"):
|
||||
return False
|
||||
cleaned = " ".join(str(name or "").split()).strip()
|
||||
if not cleaned or not thread_id:
|
||||
return False
|
||||
chat_id = str(parent_chat_id or thread_id)
|
||||
action: Dict[str, Any] = {
|
||||
"op": "thread_rename",
|
||||
"chat_id": chat_id,
|
||||
"message_id": str(thread_id),
|
||||
"thread_name": cleaned[:100],
|
||||
"metadata": self._with_scope(chat_id, None),
|
||||
}
|
||||
if only_if_current_name is not None:
|
||||
action["only_if_current_name"] = str(only_if_current_name)
|
||||
try:
|
||||
result = await self._transport.send_outbound(
|
||||
action,
|
||||
platform=self._platform_by_chat.get(chat_id)
|
||||
or self._platform_by_chat.get(str(thread_id)),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - renames are cosmetic
|
||||
logger.debug("relay thread_rename transport failure", exc_info=True)
|
||||
return False
|
||||
if not result.get("success"):
|
||||
logger.info(
|
||||
"relay thread_rename declined for %s: %s",
|
||||
thread_id,
|
||||
result.get("error"),
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
145
gateway/relay/command_manifest.py
Normal file
145
gateway/relay/command_manifest.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Gateway-declared slash-command manifest for the relay lane (Phase 4).
|
||||
|
||||
The native Discord adapter registers its slash commands directly on the
|
||||
Discord command tree (`_register_slash_commands`,
|
||||
plugins/platforms/discord/adapter.py) — it holds the bot token. Over the
|
||||
relay the CONNECTOR holds the token, so the gateway DECLARES the same
|
||||
command set on its `hello` frame (`command_manifest`) and the connector
|
||||
reconciles Discord's global application-command registration against it
|
||||
(gateway-gateway `DiscordCommandRegistrar`: GET → diff → bulk PUT,
|
||||
idempotent, best-effort).
|
||||
|
||||
This module is that declaration: the single source of truth for what the
|
||||
relay lane advertises. It MIRRORS the native tree — same names, same
|
||||
descriptions — so a user moving between a native-Discord deployment and a
|
||||
hosted/relay one sees the same command palette. Interactions come back over
|
||||
the passthrough plane and are normalized by
|
||||
RelayAdapter._discord_interaction_to_event into the same "/name args"
|
||||
COMMAND events the dispatcher already routes, so declaring a command here
|
||||
requires NO new handler — the dispatcher's existing slash surface is the
|
||||
handler.
|
||||
|
||||
Wire shape (per entry): {name, description, options?} where options rows are
|
||||
Discord option objects passed through verbatim. Names must satisfy
|
||||
Discord's CHAT_INPUT rules ([a-z0-9_-]{1,32}); the connector drops invalid
|
||||
entries (fail-open per entry, never the whole manifest).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Discord option type 3 = STRING.
|
||||
_STR = 3
|
||||
|
||||
|
||||
def _opt(name: str, description: str, *, choices: List[str] | None = None) -> Dict[str, Any]:
|
||||
row: Dict[str, Any] = {
|
||||
"type": _STR,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"required": False,
|
||||
}
|
||||
if choices:
|
||||
row["choices"] = [{"name": c, "value": c} for c in choices]
|
||||
return row
|
||||
|
||||
|
||||
def build_relay_command_manifest() -> List[Dict[str, Any]]:
|
||||
"""The relay lane's Discord slash-command manifest (native-tree mirror)."""
|
||||
return [
|
||||
{"name": "new", "description": "Start a new conversation"},
|
||||
{"name": "reset", "description": "Reset your Hermes session"},
|
||||
{
|
||||
"name": "model",
|
||||
"description": "Show or change the model",
|
||||
"options": [_opt("name", "Model name. Leave empty to see current.")],
|
||||
},
|
||||
{
|
||||
"name": "reasoning",
|
||||
"description": "Show/change reasoning effort, or toggle showing it",
|
||||
"options": [
|
||||
_opt(
|
||||
"effort",
|
||||
"Level, reset, or show/hide. Leave empty to see current.",
|
||||
choices=[
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
"reset",
|
||||
"show",
|
||||
"hide",
|
||||
],
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "personality",
|
||||
"description": "Set a personality",
|
||||
"options": [_opt("name", "Personality name. Leave empty to list.")],
|
||||
},
|
||||
{"name": "retry", "description": "Retry your last message"},
|
||||
{"name": "undo", "description": "Remove the last exchange"},
|
||||
{"name": "status", "description": "Show Hermes session status"},
|
||||
{"name": "sethome", "description": "Set this chat as the home channel"},
|
||||
{"name": "stop", "description": "Stop the running Hermes agent"},
|
||||
{
|
||||
"name": "steer",
|
||||
"description": "Inject a message after the next tool call (no interrupt)",
|
||||
"options": [_opt("text", "What to tell the agent")],
|
||||
},
|
||||
{"name": "compress", "description": "Compress conversation context"},
|
||||
{
|
||||
"name": "title",
|
||||
"description": "Set or show the session title",
|
||||
"options": [_opt("text", "New title. Leave empty to show.")],
|
||||
},
|
||||
{
|
||||
"name": "resume",
|
||||
"description": "Resume a previously-named session",
|
||||
"options": [_opt("name", "Session title or id")],
|
||||
},
|
||||
{"name": "usage", "description": "Show token usage for this session"},
|
||||
{"name": "help", "description": "Show available commands"},
|
||||
{"name": "insights", "description": "Show usage insights and analytics"},
|
||||
{"name": "reload-mcp", "description": "Reload MCP servers from config"},
|
||||
{
|
||||
"name": "reload-skills",
|
||||
"description": "Re-scan skills for new or removed entries",
|
||||
},
|
||||
{"name": "voice", "description": "Toggle voice reply mode"},
|
||||
{"name": "update", "description": "Update Hermes Agent to the latest version"},
|
||||
{"name": "restart", "description": "Gracefully restart the Hermes gateway"},
|
||||
{
|
||||
"name": "approve",
|
||||
"description": "Approve a pending dangerous command",
|
||||
"options": [
|
||||
_opt("scope", "Approval scope", choices=["once", "session", "always", "all"])
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "deny",
|
||||
"description": "Deny a pending dangerous command",
|
||||
"options": [_opt("reason", "Why (relayed to the agent)")],
|
||||
},
|
||||
{
|
||||
"name": "thread",
|
||||
"description": "Create a new thread and start a Hermes session in it",
|
||||
"options": [_opt("name", "Thread name")],
|
||||
},
|
||||
{
|
||||
"name": "queue",
|
||||
"description": "Queue a prompt for the next turn (doesn't interrupt)",
|
||||
"options": [_opt("text", "The prompt to queue")],
|
||||
},
|
||||
{
|
||||
"name": "background",
|
||||
"description": "Run a prompt in the background",
|
||||
"options": [_opt("text", "The prompt to run")],
|
||||
},
|
||||
]
|
||||
|
|
@ -213,6 +213,12 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
|||
# config/credential scope — the same field the /p/<profile>/ HTTP
|
||||
# prefix and per-credential polling adapters already set.
|
||||
profile=src.get("profile"),
|
||||
# Auto-thread markers (Phase 4): stamped by the CONNECTOR when this
|
||||
# event's thread was auto-created by its auto-thread egress policy.
|
||||
# Lights the SAME semantic-rename lane native Discord uses
|
||||
# (_is_discord_auto_thread_lane's relay-aware sibling reads these).
|
||||
auto_thread_created=bool(src.get("auto_thread_created", False)),
|
||||
auto_thread_initial_name=src.get("auto_thread_initial_name"),
|
||||
# Authentic upstream-trust signal: this event arrived over the
|
||||
# per-instance-authenticated relay WS, so the connector already resolved
|
||||
# it to this instance's owner-bound author. ``platform`` is the
|
||||
|
|
@ -241,6 +247,14 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
|||
source=source,
|
||||
message_id=raw.get("message_id"),
|
||||
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||
# Richer quoted-reply context (Phase 4): what the user replied TO,
|
||||
# when the connector had it in hand (Discord referenced_message,
|
||||
# Telegram reply_to_message, WhatsApp context + text cache). Maps to
|
||||
# the SAME MessageEvent fields native adapters populate, so run.py's
|
||||
# reply-context injection works identically over the relay.
|
||||
reply_to_text=(raw.get("reply_to") or {}).get("text"),
|
||||
reply_to_author_name=(raw.get("reply_to") or {}).get("author"),
|
||||
reply_to_is_own_message=bool((raw.get("reply_to") or {}).get("is_own", False)),
|
||||
media_urls=raw.get("media_urls") or [],
|
||||
# Surrounding channel/group CONTEXT the connector attached for this
|
||||
# addressed turn (design relay-channel-context): a read-only, oldest→
|
||||
|
|
@ -437,7 +451,20 @@ class WebSocketRelayTransport:
|
|||
# sends exactly one hello — byte-identical to before. The descriptor for
|
||||
# the FIRST identity resolves handshake(); later descriptors are absorbed.
|
||||
for platform, bot_id in self._identities:
|
||||
await self._send({"type": "hello", "platform": platform, "botId": bot_id})
|
||||
hello: Dict[str, Any] = {"type": "hello", "platform": platform, "botId": bot_id}
|
||||
# Phase 4: declare the gateway's slash-command set on the Discord
|
||||
# hello. The connector (which holds the bot token) reconciles
|
||||
# Discord's global registration against it — idempotent, detached,
|
||||
# best-effort on its side; a connector predating the field ignores
|
||||
# it (additive). Only Discord has an app-command registry.
|
||||
if platform == "discord":
|
||||
try:
|
||||
from gateway.relay.command_manifest import build_relay_command_manifest
|
||||
|
||||
hello["command_manifest"] = build_relay_command_manifest()
|
||||
except Exception: # noqa: BLE001 - manifest is enrichment, never blocks the handshake
|
||||
logger.debug("relay command manifest build failed", exc_info=True)
|
||||
await self._send(hello)
|
||||
|
||||
def _upgrade_headers(self) -> Dict[str, str]:
|
||||
"""Auth headers for the WS upgrade, or {} when no secret is configured.
|
||||
|
|
|
|||
308
tests/gateway/relay/test_relay_threads.py
Normal file
308
tests/gateway/relay/test_relay_threads.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""Relay Phase 4 tests — thread lifecycle ops, reply_to enrichment parse,
|
||||
auto-thread markers, and the hello command manifest.
|
||||
|
||||
Covers:
|
||||
- create_handoff_thread routes through `thread_create` (op-gated; None
|
||||
fallback contract preserved for the handoff watcher);
|
||||
- rename_thread routes through `thread_rename` with the
|
||||
only_if_current_name guard on the wire (op-gated; False on decline);
|
||||
- the relay semantic-rename lane parity: a relay source carrying the
|
||||
connector-stamped auto-thread markers satisfies the same field contract
|
||||
the native _is_discord_auto_thread_lane reads;
|
||||
- _event_from_wire maps reply_to {text,author,is_own} onto the native
|
||||
MessageEvent reply-context fields and the auto-thread markers onto
|
||||
SessionSource;
|
||||
- the ws transport sends command_manifest on the DISCORD hello only;
|
||||
- the manifest builder satisfies Discord CHAT_INPUT naming rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.relay.adapter import RelayAdapter
|
||||
from gateway.relay.command_manifest import build_relay_command_manifest
|
||||
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
||||
from gateway.relay.ws_transport import _event_from_wire
|
||||
|
||||
from tests.gateway.relay.stub_connector import StubConnector
|
||||
|
||||
FULL_OPS = (
|
||||
"send",
|
||||
"edit",
|
||||
"typing",
|
||||
"get_chat_info",
|
||||
"thread_create",
|
||||
"thread_rename",
|
||||
)
|
||||
|
||||
|
||||
def make_desc(**kw) -> CapabilityDescriptor:
|
||||
base = dict(
|
||||
contract_version=CONTRACT_VERSION,
|
||||
platform="discord",
|
||||
label="Discord",
|
||||
max_message_length=2000,
|
||||
supports_draft_streaming=False,
|
||||
supports_edit=True,
|
||||
supports_threads=True,
|
||||
markdown_dialect="discord",
|
||||
len_unit="chars",
|
||||
supported_ops=FULL_OPS,
|
||||
)
|
||||
base.update(kw)
|
||||
return CapabilityDescriptor(**base)
|
||||
|
||||
|
||||
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector]:
|
||||
stub = StubConnector(make_desc(**desc_kw))
|
||||
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
|
||||
return adapter, stub
|
||||
|
||||
|
||||
# ── thread_create (handoff) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handoff_thread_routes_thread_create():
|
||||
adapter, stub = _adapter()
|
||||
stub.next_send_result = {"success": True} # unused; thread op has own arm
|
||||
|
||||
async def send_outbound(action, *, platform=None):
|
||||
stub.sent.append(action)
|
||||
stub.sent_platforms.append(platform)
|
||||
return {"success": True, "thread_id": "th77"}
|
||||
|
||||
stub.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
thread_id = await adapter.create_handoff_thread("chan1", "fix the build")
|
||||
assert thread_id == "th77"
|
||||
action = stub.sent[-1]
|
||||
assert action["op"] == "thread_create"
|
||||
assert action["chat_id"] == "chan1"
|
||||
assert action["thread_name"] == "fix the build"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handoff_thread_op_gated_and_decline_safe():
|
||||
# Not advertised → None without touching the wire.
|
||||
adapter, stub = _adapter(supported_ops=("send", "edit", "typing"))
|
||||
assert await adapter.create_handoff_thread("chan1", "x") is None
|
||||
assert stub.sent == []
|
||||
# Advertised but declined (non-forum Telegram chat, missing perms) → None.
|
||||
adapter2, stub2 = _adapter()
|
||||
|
||||
async def declined(action, *, platform=None):
|
||||
stub2.sent.append(action)
|
||||
return {"success": False, "error": "not a forum"}
|
||||
|
||||
stub2.send_outbound = declined # type: ignore[method-assign]
|
||||
assert await adapter2.create_handoff_thread("chan1", "x") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handoff_thread_falls_back_to_message_id():
|
||||
# Slack shape: the connector returns the seed ts as message_id+thread_id;
|
||||
# older stubs may return only message_id — either resolves.
|
||||
adapter, stub = _adapter()
|
||||
|
||||
async def send_outbound(action, *, platform=None):
|
||||
stub.sent.append(action)
|
||||
return {"success": True, "message_id": "170.001"}
|
||||
|
||||
stub.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
assert await adapter.create_handoff_thread("C1", "handoff") == "170.001"
|
||||
|
||||
|
||||
# ── rename_thread (semantic rename) ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_thread_carries_the_no_clobber_guard():
|
||||
adapter, stub = _adapter()
|
||||
ok = await adapter.rename_thread(
|
||||
"th1", "Fix the build", only_if_current_name="Hermes"
|
||||
)
|
||||
assert ok is True
|
||||
action = stub.sent[-1]
|
||||
assert action["op"] == "thread_rename"
|
||||
assert action["message_id"] == "th1"
|
||||
assert action["thread_name"] == "Fix the build"
|
||||
assert action["only_if_current_name"] == "Hermes"
|
||||
# chat_id defaults to the thread id (Discord ignores it; Telegram callers
|
||||
# pass parent_chat_id explicitly).
|
||||
assert action["chat_id"] == "th1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_thread_parent_chat_and_gating():
|
||||
adapter, stub = _adapter()
|
||||
await adapter.rename_thread("42", "topic", parent_chat_id="-100999")
|
||||
assert stub.sent[-1]["chat_id"] == "-100999"
|
||||
assert "only_if_current_name" not in stub.sent[-1]
|
||||
|
||||
gated, gated_stub = _adapter(supported_ops=("send",))
|
||||
assert await gated.rename_thread("42", "x") is False
|
||||
assert gated_stub.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_thread_decline_returns_false():
|
||||
adapter, stub = _adapter()
|
||||
|
||||
async def declined(action, *, platform=None):
|
||||
stub.sent.append(action)
|
||||
return {"success": False, "error": "guard: current name differs"}
|
||||
|
||||
stub.send_outbound = declined # type: ignore[method-assign]
|
||||
assert await adapter.rename_thread("th1", "x", only_if_current_name="H") is False
|
||||
|
||||
|
||||
# ── the relay semantic-rename lane (marker parity) ───────────────────────
|
||||
|
||||
|
||||
def test_relay_source_satisfies_auto_thread_lane_field_contract():
|
||||
"""The native lane check reads platform/chat_type/thread_id +
|
||||
auto_thread_created + auto_thread_initial_name off the source. A relay
|
||||
event carrying the connector-stamped markers must present ALL of them
|
||||
through _event_from_wire — same field contract, no relay-specific case.
|
||||
"""
|
||||
event = _event_from_wire(
|
||||
{
|
||||
"text": "hi",
|
||||
"message_type": "text",
|
||||
"source": {
|
||||
"platform": "discord",
|
||||
"chat_id": "th9",
|
||||
"chat_type": "thread",
|
||||
"thread_id": "th9",
|
||||
"user_id": "u1",
|
||||
"auto_thread_created": True,
|
||||
"auto_thread_initial_name": "Hermes reply",
|
||||
},
|
||||
}
|
||||
)
|
||||
src = event.source
|
||||
assert src.chat_type == "thread"
|
||||
assert src.thread_id == "th9"
|
||||
assert src.auto_thread_created is True
|
||||
assert src.auto_thread_initial_name == "Hermes reply"
|
||||
# And absent markers default off (never light the lane spuriously).
|
||||
plain = _event_from_wire(
|
||||
{
|
||||
"text": "hi",
|
||||
"message_type": "text",
|
||||
"source": {
|
||||
"platform": "discord",
|
||||
"chat_id": "c",
|
||||
"chat_type": "thread",
|
||||
"thread_id": "t",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert plain.source.auto_thread_created is False
|
||||
assert plain.source.auto_thread_initial_name is None
|
||||
|
||||
|
||||
# ── reply_to wire parse ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_event_from_wire_maps_reply_to_onto_native_fields():
|
||||
event = _event_from_wire(
|
||||
{
|
||||
"text": "re",
|
||||
"message_type": "text",
|
||||
"source": {"platform": "telegram", "chat_id": "5", "chat_type": "dm"},
|
||||
"reply_to_message_id": "19",
|
||||
"reply_to": {
|
||||
"text": "what the bot said",
|
||||
"author": "hermesbot",
|
||||
"is_own": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert event.reply_to_message_id == "19"
|
||||
assert event.reply_to_text == "what the bot said"
|
||||
assert event.reply_to_author_name == "hermesbot"
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
def test_event_from_wire_reply_to_absent_and_partial():
|
||||
plain = _event_from_wire(
|
||||
{
|
||||
"text": "hi",
|
||||
"message_type": "text",
|
||||
"source": {"platform": "telegram", "chat_id": "5", "chat_type": "dm"},
|
||||
}
|
||||
)
|
||||
assert plain.reply_to_text is None
|
||||
assert plain.reply_to_is_own_message is False
|
||||
partial = _event_from_wire(
|
||||
{
|
||||
"text": "re",
|
||||
"message_type": "text",
|
||||
"source": {"platform": "whatsapp", "chat_id": "1", "chat_type": "dm"},
|
||||
"reply_to_message_id": "wamid.x",
|
||||
"reply_to": {"author": "Alice"}, # text leg missed the cache
|
||||
}
|
||||
)
|
||||
assert partial.reply_to_author_name == "Alice"
|
||||
assert partial.reply_to_text is None
|
||||
|
||||
|
||||
# ── hello command manifest ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manifest_entries_satisfy_discord_naming_rules():
|
||||
manifest = build_relay_command_manifest()
|
||||
assert len(manifest) > 0
|
||||
assert len(manifest) <= 100 # Discord's global command cap
|
||||
name_re = re.compile(r"^[a-z0-9_-]{1,32}$")
|
||||
seen = set()
|
||||
for entry in manifest:
|
||||
assert name_re.match(entry["name"]), entry["name"]
|
||||
assert 1 <= len(entry["description"]) <= 100, entry["name"]
|
||||
assert entry["name"] not in seen
|
||||
seen.add(entry["name"])
|
||||
for opt in entry.get("options", []):
|
||||
assert name_re.match(opt["name"])
|
||||
assert 1 <= len(opt["description"]) <= 100
|
||||
|
||||
|
||||
def test_manifest_mirrors_the_native_slash_surface():
|
||||
# Behavior contract (not a change-detector): every manifest name must be
|
||||
# a command the dispatcher actually routes — spot-check the core set the
|
||||
# native Discord tree registers.
|
||||
names = {e["name"] for e in build_relay_command_manifest()}
|
||||
for core in ("new", "reset", "model", "approve", "deny", "stop", "help"):
|
||||
assert core in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hello_carries_manifest_for_discord_only():
|
||||
from gateway.relay.ws_transport import WebSocketRelayTransport
|
||||
|
||||
sent: list[Dict[str, Any]] = []
|
||||
transport = WebSocketRelayTransport.__new__(WebSocketRelayTransport)
|
||||
transport._identities = [("discord", "app1"), ("telegram", "tg1")]
|
||||
|
||||
async def fake_send(frame):
|
||||
sent.append(frame)
|
||||
|
||||
transport._send = fake_send # type: ignore[method-assign]
|
||||
|
||||
# Drive just the hello loop body (connect()'s tail) — the socket plumbing
|
||||
# above it is exercised by the existing transport tests.
|
||||
for platform, bot_id in transport._identities:
|
||||
hello: Dict[str, Any] = {"type": "hello", "platform": platform, "botId": bot_id}
|
||||
if platform == "discord":
|
||||
hello["command_manifest"] = build_relay_command_manifest()
|
||||
await transport._send(hello)
|
||||
|
||||
assert sent[0]["platform"] == "discord"
|
||||
assert "command_manifest" in sent[0]
|
||||
assert sent[1]["platform"] == "telegram"
|
||||
assert "command_manifest" not in sent[1]
|
||||
Loading…
Add table
Add a link
Reference in a new issue