Repository navigation
fix(discord): slash, /thread and voice turns carry a message turn's prompt inputs #128788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c2727ba
b993d20
2a3f04a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F5 β [P2] Scope voice deduplication to the receiving bot and bound conversationA valid second bot's turn can be discarded before reaching the new prompt/skill construction. 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: 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed by reading this head and
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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| await adapter.handle_message(event) | ||
|
|
||
| def _should_send_voice_reply( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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}" | |||||||||||||||||
|
|
@@ -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), | |||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F3 β [P2] Repair exact-thread skill precedence in the shared resolverThis new slash startup lookup adopts a resolver whose implementation contradicts its 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
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 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reproduced on 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 speechThis event can carry the correct bound skill and still never reach the agent. The adapter's slash gate accepts Reproduce: configure The composed probe used real Discord Guild/Member/Role objects and the actual gateway admission functions, without overriding Voice is the sibling manifestation: Consequence: role-based deployments cannot use these agent-producing turn types even though Discord's gate admits the user. Adding 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed by reading this head and
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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Native slash and |
|||||||||||||||||
| ) | |||||||||||||||||
|
|
|||||||||||||||||
| # --- Thread creation helpers --- | |||||||||||||||||
|
|
@@ -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) | |||||||||||||||||
|
|
@@ -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 | |||||||||||||||||
|
|
@@ -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 | |||||||||||||||||
|
|
|||||||||||||||||
| 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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_gatewaypasses on #130303's head357e77d092.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.