Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions gateway/run_agent_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,11 +731,9 @@ def _s(v) -> str:
if src.platform == Platform.DISCORD:
from gateway.session import _discord_tools_loaded
discord_tools = "1" if _discord_tools_loaded() else "0"
# message_id: only PRESENCE is rendered (the id itself arrives per-turn in the user
# message) β€” keying on the value would re-render every message for zero byte change.
# message_id is not rendered (value nor presence): it arrives per-turn in the user message.
discord_ids = (
_s(src.guild_id), _s(src.parent_chat_id), _s(src.thread_id), _s(src.chat_id),
"1" if src.message_id else "0",
)
# Slack's capability-aware platform note is gated on _slack_tools_loaded() β€” the gate state must
# be in the key (same parity contract as the Discord gate above) so a config / MCP-registration
Expand Down
35 changes: 29 additions & 6 deletions gateway/run_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,31 @@ def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript
def _voice_input_source(adapter, guild_id: int, user_id: int, text_ch_id) -> SessionSource:
"""Bound text channel's own source when available (voice shares the text conversation's
session), else a synthetic one."""
# The speaker's display name, as their typed messages carry it: the pinned session-context
# prompt renders it, so a bare id here re-rendered it on every spoken/typed switch.
client = getattr(adapter, "_client", None)
guild = client.get_guild(guild_id) if client else None
member = guild.get_member(int(user_id)) if guild else None
display_name = getattr(member, "display_name", None)
user_name = display_name if isinstance(display_name, str) and display_name else None
if source_data := getattr(adapter, "_voice_sources", {}).get(guild_id):
source = SessionSource.from_dict(source_data)
source.user_id = source.user_name = str(user_id)
# get_member() is cache-only: on a miss the /voice join invoker keeps the name bound at
# join, never another participant's.
if user_name is None and source.user_id == str(user_id):
user_name = source.user_name
source.user_id, source.user_name = str(user_id), user_name or str(user_id)
else:
source = SessionSource(
platform=Platform.DISCORD, chat_id=str(text_ch_id), user_id=str(user_id),
user_name=str(user_id), chat_type="channel",
user_name=user_name or str(user_id), chat_type="channel",
profile=getattr(adapter, "_owner_profile", None))
# The stored source is a join-time copy; a typed message reads the channel's current name and
# topic, so a rename or topic edit would otherwise flip the pinned prompt on every switch.
channel = client.get_channel(int(text_ch_id)) if client else None
if channel is not None and callable(labels := getattr(adapter, "_guild_channel_labels", None)):
if isinstance(current := labels(channel), tuple):
source.chat_name, source.chat_topic = current
# Serialization drops transport provenance; auth must still follow the receiving bot.
source._transport_adapter_ref = weakref.ref(adapter)
return source
Expand Down Expand Up @@ -281,18 +298,24 @@ async def _handle_voice_channel_input(
safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone")
safe_text = safe_text.replace("@here", "@\u200bhere")
await channel.send(t("gateway.voice.transcript_echo", user=user_id, text=safe_text))
# Bound text channel's channel_prompt: voice input gets the same per-channel context.
channel_prompt = None
# Bound text channel's channel_prompt and skills: voice input gets the same per-channel
# context, and a first spoken turn opens the session, the only point skills load.
# A thread inherits its parent's bindings, as its typed messages do.
channel_prompt = auto_skill = None
parent_id = source.parent_chat_id or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified repair and consolidation dependency. Current tests now cover the bound thread's parent and current conversation labels; those voice parity cases fail on c2727ba and pass here. Please retain them while folding #129303, #130303 and #130308 into this PR. Passing parent_id is only the caller half: exact-thread precedence is repaired in the shared resolver, current speaker role handoff in the companion voice change, and cross-bot dedup in the companion runner change. Submit their combined behavior as one reviewed boundary. Capture-generation lifetime still needs its own explicit acceptance regression rather than being inferred from this parent lookup.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming the voice parent and label repair. The parity cases stay in this PR.

On consolidation: we're keeping #129303, #130303 and #130308 as separate PRs. Each fixes a different bug class (channel-skill resolver precedence, the role grant, cross-bot voice dedup), each has its own issue and invariant test, and each is correct without the others. This P0 stays the prompt-input fix so it can merge on its own. The full reasoning and the native role-handoff disposition are on #130303 (#130303 (comment)). Your test_admitted_native_role_member_remains_admitted_in_gateway passes on #130303's head 357e77d092.

The capture-to-transcription lifetime (F6) has its own acceptance regression in #130312 (tests/gateway/test_discord_voice_stale_binding.py, issue #130311). It is not inferred from this parent lookup.

if callable(resolver := getattr(adapter, "_resolve_channel_prompt", None)):
with suppress(Exception):
resolved = resolver(str(text_ch_id))
resolved = resolver(str(text_ch_id), parent_id)
channel_prompt = resolved if isinstance(resolved, str) else None
if callable(skills := getattr(adapter, "_resolve_channel_skills", None)):
bound = skills(str(text_ch_id), parent_id)
auto_skill = bound if isinstance(bound, list) else None
# Synthetic MessageEvent for the normal pipeline; the SimpleNamespace raw_message lets
# _get_guild_id() extract guild_id so _send_voice_reply() plays audio in the voice channel.
event = MessageEvent(
source=source, text=transcript, message_type=MessageType.VOICE,
raw_message=SimpleNamespace(guild_id=guild_id, guild=None),
channel_prompt=channel_prompt)
channel_prompt=channel_prompt, auto_skill=auto_skill)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F5 β€” [P2] Scope voice deduplication to the receiving bot and bound conversation

A valid second bot's turn can be discarded before reaching the new prompt/skill construction. _handle_voice_channel_input receives adapter and rebuilds transport provenance, but calls _is_duplicate_voice_transcript(guild_id, user_id, transcript). That helper owns one runner-wide dictionary keyed by only (guild_id, user_id).

Reproduce: serve two independent Discord adapter/profile owners in the same guild, with active voice bindings. Submit the same captured utterance for user 42 to both callbacks within the 12-second window. My probe registered the second adapter under its own served multiplex profile and exercised the actual callback and source routing:

same utterance, bot A: dispatched 1
same utterance, bot B: dispatched 0
different utterance, bot B: dispatched 1
same-bot duplicate control: total dispatched 1

Thus the second transport is live; it is suppressed by another bot's dedup state. The helper cannot distinguish those captures because its interface has no transport or conversation discriminator. The first completed transcription wins regardless of which bot/profile should handle the other turn.

Consequence: a receiving bot silently loses legitimate speech and never applies its own bound prompt/skills. Transport provenance in the eventual source does not help if execution returns before constructing that event.

Repair: include the receiving transport identity and bound conversation/binding generation in the dedup key, and keep retention bounded. A repeated capture within the same binding should still be suppressed; another bot or a newly bound conversation must not borrow the previous binding's history.

Regression: two registered bots, identical guild/user/transcript, distinct transport owners must both dispatch. Repeat within one binding must dispatch once. Cover different bound text channels, rebind/reset and different users, and ensure no cross-profile mutable dedup state is consulted. My test executes source routing and the callback with real Discord objects, substitutes served-profile discovery, authorization admission and downstream dispatch, and does not claim a live audio run. This is also present on the implementation base.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by reading this head and main; predates this PR, which does not touch the dedup.

_bind_voice_input_callback binds _handle_voice_channel_input per adapter, but _is_duplicate_voice_transcript(guild_id, user_id, transcript) takes no adapter and keeps one runner-wide store keyed by (guild_id, user_id). Two bots served by one gateway and listening in the same guild share one 12 s window, so the second bot's copy of an utterance returns before its event is built. The helper came in with #19428 for the single-bot case. Not re-run here.

It goes into the voice follow-up: key by receiving adapter plus the bound text channel, so a repeat inside one binding is still suppressed and another bot or a new binding starts clean.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#130307 / #130308: the dedup key is now (receiving bot's profile, guild, bound text channel, speaker). Pinned: two bots each dispatch, a same-bot repeat is suppressed, a rebind dispatches again.

await adapter.handle_message(event)

def _should_send_voice_reply(
Expand Down
14 changes: 7 additions & 7 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,13 @@ def _discord_platform_notes(context: SessionContext) -> List[str]:
lines.append(f" - Thread: `{src.thread_id}` (use as `channel_id` for fetch_messages etc.)")
else:
lines.append(f" - Channel: `{src.chat_id}`")
if src.message_id:
# The volatile per-turn message id must stay OUT of this cached block (it would bust the
# agent-cache signature every message); run.py injects it into the user message instead.
lines.append(
" - Triggering message: provided per-turn in the incoming user message (use it as "
"`message_id` for reply/react/pin)"
)
# Neither the volatile id nor its presence belongs in this pinned block: slash and voice
# turns have no triggering message, so a presence-gated line flips the prompt between them
# and typed turns. run.py injects the real id into the user message when there is one.
lines.append(
" - Triggering message: when available, its ID is provided per-turn in the incoming "
"user message (use it as `message_id` for reply/react/pin)"
)
else:
lines = ["", (
"**Platform notes:** You are running inside Discord. You do NOT have access to "
Expand Down
43 changes: 26 additions & 17 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4709,8 +4709,14 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes
thread_id = str(interaction.channel_id)
else:
chat_type = "group"
# A slash turn re-pins the session-context prompt, so it names the chat exactly as a message
# there does; any other label re-renders the pinned prompt on every switch between the two.
chat_name = ""
if not is_dm and hasattr(interaction.channel, "name"):
if is_dm:
chat_name = interaction.user.name
elif is_thread:
chat_name = self._format_thread_chat_name(interaction.channel)
elif hasattr(interaction.channel, "name"):
chat_name = interaction.channel.name
if hasattr(interaction.channel, "guild") and interaction.channel.guild:
chat_name = f"{interaction.channel.guild.name} / #{chat_name}"
Expand All @@ -4732,6 +4738,8 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes
return MessageEvent(
text=text, message_type=msg_type, source=source, raw_message=interaction,
channel_prompt=self._resolve_channel_prompt(channel_id, parent_id or None),
# Bound skills load only when a session starts, and "/skill x" or "/queue" can start one.
auto_skill=self._resolve_channel_skills(channel_id, parent_id or None),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the user-visible half of the fix (a session opened by /skill x or /queue never loaded the channel's bound skill). run_turn.py loads auto_skill only when _is_new_session, so this can't double-load on an existing session. Good.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F3 β€” [P2] Repair exact-thread skill precedence in the shared resolver

This new slash startup lookup adopts a resolver whose implementation contradicts its exact channel first, then parent contract. gateway.platforms.base.resolve_channel_skills puts both IDs in ids_to_check, scans bindings in their original order, and returns the first match. Consequently an earlier parent entry shadows an exact thread entry.

A minimal runnable reproduction from the checkout/test environment:

from gateway.platforms.base import resolve_channel_skills
extra = {"channel_skill_bindings": [
    {"id": "700", "skill": "parent-default"},
    {"id": "800", "skill": "thread-specific"},
]}
actual = resolve_channel_skills(extra, "800", "700")
assert actual == ["thread-specific"], actual
# AssertionError: ['parent-default']

I also ran both entry orders through the actual typed builder, native slash builder, public /thread starter path and voice callback. At this head:

Binding order Typed Slash /thread starter Voice
Exact, parent thread-specific thread-specific thread-specific thread-specific
Parent, exact parent-default parent-default parent-default thread-specific

The last-column disagreement occurs because F1 currently omits the parent. Passing the parent to voice without repairing precedence would make all four consistently select the wrong binding.

Consequence: a new native-slash session now receives the parent skill's instructions despite an explicit thread override. Configuration reordering changes behavior without changing either binding. The shared helper defect is pre-existing (also reproduced on the implementation base); native slash consumption of it is added here.

Repair: enforce ordered resolution at resolve_channel_skills: search exact channel/thread entries first, then parent entries, preserving skill-list normalization/deduplication and explicit behavior for absent/empty entries. Use the shared fix for Discord messages, slash startup, thread starters, voice, and Slack consumers.

Regression: permute the parent/exact entry order and require the exact binding to win in both; require parent inheritance when no exact binding exists; observe the selected payload at new-session startup. Keep unrelated-channel and absent-binding controls. My composed probe used real discord.py channel/member objects and fake thread creation/voice transport; it proves builder selection, not a live Discord round trip.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced on main (f42f579cf8) with your snippet: resolve_channel_skills(extra, "800", "700") returns ['parent-default'] with the parent entry listed first, ['thread-specific'] with the order swapped.

It is in the shared helper and independent of this diff, so it is its own change: issue #129302, fix in #129303 (exact id across all bindings first, then the parent id, as resolve_channel_prompt already does; one invariant test over both entry orders, red on main). Credited to your report there. Per-entry parsing is unchanged, and Slack passes no parent so its behaviour does not move.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#129303 (issue #129302): exact id first, then the parent, in either entry order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F4 β€” [P1] Preserve the role grant through slash/thread admission and recheck it for speech

This event can carry the correct bound skill and still never reach the agent. The adapter's slash gate accepts DISCORD_ALLOWED_ROLES, but _build_slash_event calls build_source without the per-event role_authorized grant. The typed path receives that grant from _discord_message_admission. The downstream _hm_admit_event rejects a role-only principal whose source does not carry it.

Reproduce: configure DISCORD_ALLOWED_ROLES=1234, leave DISCORD_ALLOWED_USERS empty, disable Discord/global allow-all, and use a guild member holding role 1234. Compare a typed turn with native /queue investigate; then create a /thread with a starter and establish voice through a typed /voice join. The adapter's native authorization check passes.

The composed probe used real Discord Guild/Member/Role objects and the actual gateway admission functions, without overriding _is_user_authorized:

grant      typed admitted  native admitted  thread starter admitted  speech dispatches
user ID    True            True             True                     1
role       True            False            False                    0

Voice is the sibling manifestation: SessionSource.to_dict/from_dict deliberately omit the wire-invisible role flag, and _voice_input_source updates the current speaker's name/ID without recomputing their current guild-scoped role authorization. The role-only speaker is dropped before event dispatch. A typed join can establish the connection; a native join is itself subject to the admission failure.

Consequence: role-based deployments cannot use these agent-producing turn types even though Discord's gate admits the user. Adding auto_skill does not close the producer β†’ admission β†’ consumer contract.

Repair: carry the adapter-verified, current-event grant into slash and created-thread sources through a shared admission result. For speech, verify the current speaker against the receiving bot's current policy and guild member, then attach only that event's grant. Do not serialize the grant, set it merely because a role list is configured, or inherit the joining speaker's authorization.

Regression: run authorized-role, user-ID, unrelated-role, revoked-role and different-speaker cases through adapter admission and the real gateway gate, then assert actual skill-bearing dispatch/session startup. The composed role/user-ID cases above are executed; the negative cases are the required repair contract. Voice connection and thread HTTP were replaced, not authorization. This failure also reproduces on the implementation base; it predates this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by reading this head and main; predates this PR.

The voice half goes into the voice follow-up with F1/F2, with the grant recomputed for the current speaker against the guild member at dispatch, not serialized and not inherited from whoever ran /voice join. Nothing is pushed to this branch: c2727ba5 is the approved head.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Voice half: #130302 / #130303. It recomputes the grant for the current speaker against the current guild member, and is pinned through the real gateway _is_user_authorized. Refusals are pinned too: a speaker without the role, and one who left the guild. Native slash and /thread starter half: #118958 (#125620, #118966).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Complete the native role-grant handoff in this consolidated PR. Revalidating existing F4: a legitimate configured-role member passes the real _check_slash_authorization, but this event has no role verdict and the real gateway refuses its source. The failure remains after composing #129303, #130303 and #130308. The thread-starter builder has the same omitted handoff. Fold in an actor-specific, current verdict at both builders and test actual gateway admission; the voice-only fix does not close this class. Runnable positive-admission regression and evidence limits are in this review body.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native slash and /thread starter half: fixed in #130303 (357e77d092, on top of #125620 salvaged with credit). Both builders now stamp the slash gate's verdict for the interaction's own actor, and the real gateway gate admits a role member and refuses an unrelated-role actor. It stays a separate PR so this P0 stays small. Reason on #130303.

)

# --- Thread creation helpers ---
Expand Down Expand Up @@ -4766,25 +4774,18 @@ async def _handle_thread_create_slash(
await self._threads.mark_async(thread_id)
starter = (message or "").strip()
if starter and thread_id:
await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
await self._dispatch_thread_session(interaction, result["thread"], starter)

async def _dispatch_thread_session(
self, interaction: discord.Interaction, thread_id: str, thread_name: str, text: str,
) -> None:
async def _dispatch_thread_session(self, interaction: discord.Interaction, thread: Any, text: str) -> None:
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
guild_name = ""
if hasattr(interaction, "guild") and interaction.guild:
guild_name = interaction.guild.name
chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
# Inherit forum topic when the thread was created inside a forum channel.
_chan = getattr(interaction, "channel", None)
chat_topic = self._get_effective_topic(_chan, is_thread=True) if _chan else None
_parent_channel = self._thread_parent_channel(getattr(interaction, "channel", None))
_parent_id = str(getattr(_parent_channel, "id", "") or "")
# Name, topic and parent come from the thread itself, as for a message posted in it: this
# turn opens the session and pins its context prompt, which the thread's messages then reuse.
thread_id = str(thread.id)
_parent_id = self._get_parent_channel_id(thread) or ""
source = self.build_source(
chat_id=thread_id, chat_name=chat_name, chat_type="thread",
chat_id=thread_id, chat_name=self._format_thread_chat_name(thread), chat_type="thread",
user_id=str(interaction.user.id), user_name=interaction.user.display_name,
thread_id=thread_id, chat_topic=chat_topic,
thread_id=thread_id, chat_topic=self._get_effective_topic(thread, is_thread=True),
guild_id=self._interaction_guild_id(interaction), parent_chat_id=_parent_id or None,
)
_skills = self._resolve_channel_skills(thread_id, _parent_id or None)
Expand Down Expand Up @@ -5328,7 +5329,8 @@ async def _create_thread(

@staticmethod
def _thread_created(thread: Any, name: str) -> Dict[str, Any]:
return {"success": True, "thread_id": str(thread.id), "thread_name": getattr(thread, "name", None) or name}
return {"success": True, "thread_id": str(thread.id), "thread_name": getattr(thread, "name", None) or name,
"thread": thread}

# ------------------------------------------------------------------
# Auto-thread helpers
Expand Down Expand Up @@ -5789,6 +5791,13 @@ def _format_thread_chat_name(self, thread: Any) -> str:
return f"{parent_name} / {thread_name}"
return thread_name

def _guild_channel_labels(self, channel: Any) -> tuple[str, Optional[str]]:
"""``(chat_name, chat_topic)`` for a guild channel or thread, as a message posted there gets them."""
if isinstance(channel, discord.Thread):
return self._format_thread_chat_name(channel), self._get_effective_topic(channel, is_thread=True)
name, guild = getattr(channel, "name", str(channel.id)), getattr(channel, "guild", None)
return (f"{guild.name} / #{name}" if guild else name), self._get_effective_topic(channel)

# ------------------------------------------------------------------
# Attachment download helpers
# Prefer the authenticated bot session (``att.read()``): CDN URLs increasingly 403 without
Expand Down
4 changes: 3 additions & 1 deletion tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,9 @@ async def capture_handle(event):

adapter.handle_message = capture_handle

await adapter._dispatch_thread_session(interaction, "555", "Planning", "Hello!")
thread = SimpleNamespace(id=555, name="Planning", guild=interaction.guild, topic=None,
parent=SimpleNamespace(id=100, name="general", guild=interaction.guild))
await adapter._dispatch_thread_session(interaction, thread, "Hello!")

assert len(captured_events) == 1
event = captured_events[0]
Expand Down
148 changes: 148 additions & 0 deletions tests/gateway/test_discord_turn_prompt_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Discord turns built outside on_message carry the same prompt inputs as a message in that chat.

Native slash commands (``/skill``, ``/queue``, ``/plan``), the ``/thread`` starter and voice-channel
input are human turns: each re-pins the session-context prompt and the channel prompt. Built with
another chat label (DM: none; thread: without its parent channel), another topic or a bare user id,
they re-rendered the pinned prompt, and the next typed message rendered it back: a prompt-cache miss
each way. Built without ``auto_skill``, a session they opened never loaded the channel's bound skill.
Voice rebuilds its source from a ``/voice join`` copy, so it must also see the parent's bindings from a
thread and the channel's current name and topic.
"""

from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

import gateway.run as gateway_run
import plugins.platforms.discord.adapter as discord_platform
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import build_session_context
from plugins.platforms.discord.adapter import DiscordAdapter


class _DM:
def __init__(self, channel_id):
self.id, self.name = channel_id, "dm"


class _Text:
def __init__(self, channel_id, name="ops", topic="Incident triage"):
self.id, self.name, self.topic = channel_id, name, topic
self.guild = SimpleNamespace(id=1, name="Hermes Server")


class _Thread:
def __init__(self, channel_id, parent, name="incident-42"):
self.id, self.name, self.parent, self.parent_id = channel_id, name, parent, parent.id
self.guild, self.topic = parent.guild, None


_USER = SimpleNamespace(id=42, display_name="Alice", name="alice")


def _adapter(monkeypatch, bound_id) -> DiscordAdapter:
monkeypatch.setattr(discord_platform.discord, "DMChannel", _DM, raising=False)
monkeypatch.setattr(discord_platform.discord, "Thread", _Thread, raising=False)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake", extra={
"channel_prompts": {str(bound_id): "Answer in haiku."},
"channel_skill_bindings": [{"id": str(bound_id), "skill": "triage"}]}))
adapter._client = SimpleNamespace(
user=SimpleNamespace(id=999), get_channel=lambda _id: None,
get_guild=lambda _id: SimpleNamespace(get_member=lambda _uid: _USER))
adapter._text_batch_delay_seconds = 0
adapter._discord_history_backfill = lambda: False
adapter.handle_message = AsyncMock()
return adapter


async def _typed(adapter, channel):
await adapter._handle_message(SimpleNamespace(
id=123, content="what broke?", mentions=[], attachments=[], reference=None,
created_at=datetime.now(timezone.utc), channel=channel, author=_USER,
guild=getattr(channel, "guild", None)))
return adapter.handle_message.await_args.args[0]


def _assert_same_prompt_inputs(typed, other):
"""typed -> other -> typed through the real pins leaves one context prompt and one channel prompt."""
runner = object.__new__(gateway_run.GatewayRunner)
config = GatewayConfig(platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="x")})
pinned = []
for event in (typed, other, typed):
context = build_session_context(event.source, config)
channel_prompt, _ = runner._pinned_channel_inputs(
"k", event.channel_prompt, event.source, internal=False)
pinned.append((runner._pinned_session_context_prompt(context, False, "k"), channel_prompt))
assert typed.channel_prompt == "Answer in haiku."
assert len(set(pinned)) == 1, (pinned[0][0], pinned[1][0])
assert other.auto_skill == typed.auto_skill == ["triage"]


def _parent():
return _Text(700)


def _interaction(channel):
return SimpleNamespace(
channel=channel, channel_id=channel.id, guild=getattr(channel, "guild", None), guild_id=None,
user=_USER, response=SimpleNamespace(defer=AsyncMock()), followup=SimpleNamespace(send=AsyncMock()),
delete_original_response=AsyncMock())


@pytest.mark.asyncio
@pytest.mark.parametrize("tools", [False, True], ids=["no-discord-tools", "discord-tools"])
@pytest.mark.parametrize("case", ["dm", "channel", "thread", "thread-starter"])
async def test_slash_and_thread_starter_turns_match_a_message_turn(monkeypatch, case, tools):
# With Discord tools on, the pinned notes list IDs; a slash turn has no triggering message.
monkeypatch.setattr("gateway.session._discord_tools_loaded", lambda: tools)
parent = _parent()
channel = {"dm": _DM(500), "channel": parent}.get(case) or _Thread(800, parent)
adapter = _adapter(monkeypatch, channel.id)
adapter._check_slash_authorization = AsyncMock(return_value=True)
typed = await _typed(adapter, channel)
if case == "thread-starter":
channel.send = AsyncMock()
parent.create_thread = AsyncMock(return_value=channel)
await adapter._handle_thread_create_slash(_interaction(parent), channel.name, "what broke?")
else:
await adapter._run_simple_slash(_interaction(channel), "/skill triage what broke?")
assert adapter.handle_message.await_count == 2
_assert_same_prompt_inputs(typed, adapter.handle_message.await_args.args[0])


@pytest.mark.asyncio
@pytest.mark.parametrize("case", ["channel", "thread-under-bound-parent", "renamed-after-join", "speaker-uncached"])
async def test_voice_channel_turn_matches_a_typed_turn(monkeypatch, case):
parent = _parent()
channel = _Thread(800, parent) if case == "thread-under-bound-parent" else parent
adapter = _adapter(monkeypatch, parent.id)
join = adapter._build_slash_event(
SimpleNamespace(channel=channel, channel_id=channel.id, guild=parent.guild, guild_id=1, user=_USER),
"/voice join")
adapter._voice_text_channels = {1: channel.id}
adapter._voice_sources = {1: join.source.to_dict()}
adapter._client.get_channel = {channel.id: channel}.get
if case == "renamed-after-join":
channel.name, channel.topic = "renamed", "New topic"
if case == "speaker-uncached":
adapter._client.get_guild = lambda _id: SimpleNamespace(get_member=lambda _uid: None)
typed = await _typed(adapter, channel)
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {Platform.DISCORD: adapter}
runner._voice_mode = {}
runner._session_db = None
runner.session_store = MagicMock()
runner._is_user_authorized = lambda source: True

await runner._handle_voice_channel_input(1, _USER.id, "what broke?", adapter=adapter)

spoken = adapter.handle_message.await_args.args[0]
assert spoken is not typed
_assert_same_prompt_inputs(typed, spoken)
# The join-time name belongs to the joiner only; another uncached speaker never borrows it.
other = runner._voice_input_source(adapter, 1, 43, channel.id).user_name
assert other == ("43" if case == "speaker-uncached" else "Alice")
2 changes: 1 addition & 1 deletion tests/gateway/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ async def test_input_resolves_channel_prompt(self, runner):
mock_adapter._resolve_channel_prompt = MagicMock(return_value="Be terse in #dev.")
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
mock_adapter._resolve_channel_prompt.assert_called_once_with("123")
mock_adapter._resolve_channel_prompt.assert_called_once_with("123", None)
event = mock_adapter.handle_message.call_args[0][0]
assert event.channel_prompt == "Be terse in #dev."

Expand Down