-
Notifications
You must be signed in to change notification settings - Fork 46k
fix(backend): deliver AutoPilot clarifying questions to bot platforms #14435
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
base: dev
Are you sure you want to change the base?
Changes from all commits
09dd2d6
3bb59cb
d039e8c
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 |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Native Discord buttons for ask_question, and the click handler that turns | ||
| a press into an ordinary inbound message. | ||
|
|
||
| Kept out of ``adapter.py`` (already large) — the only thing the adapter needs | ||
| from here is ``build_choice_view``. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| import discord | ||
|
|
||
| from backend.copilot.bot import choices | ||
| from backend.copilot.bot.adapters.base import ( | ||
| ChannelType, | ||
| MessageCallback, | ||
| MessageContext, | ||
| PlatformAdapter, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _EXPIRED_NOTICE = "This question has expired — type your answer instead." | ||
|
|
||
|
|
||
| def build_choice_view( | ||
| adapter: PlatformAdapter, | ||
| on_message: MessageCallback, | ||
| token: str, | ||
| options: list[str], | ||
| ) -> discord.ui.View: | ||
| """One button per option (View auto-wraps into rows of 5); a click | ||
| resolves the answer via ``bot.choices`` and feeds it through | ||
| ``on_message``, exactly like a normal typed reply.""" | ||
| view = discord.ui.View(timeout=choices.CHOICE_TTL) | ||
| for index, option in enumerate(options): | ||
| view.add_item(_ChoiceButton(adapter, on_message, token, index, option)) | ||
| return view | ||
|
Comment on lines
+18
to
+37
Contributor
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. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Register choice handlers after startup. 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class _ChoiceButton(discord.ui.Button): | ||
| def __init__( | ||
| self, | ||
| adapter: PlatformAdapter, | ||
| on_message: MessageCallback, | ||
| token: str, | ||
| index: int, | ||
| label: str, | ||
| ) -> None: | ||
| super().__init__(style=discord.ButtonStyle.secondary, label=label[:80]) | ||
| self._adapter = adapter | ||
| self._on_message = on_message | ||
| self._token = token | ||
| self._index = index | ||
|
|
||
| async def callback(self, interaction: discord.Interaction) -> None: | ||
| option = await choices.resolve_choice("discord", self._token, self._index) | ||
| if option is None: | ||
| await interaction.response.send_message(_EXPIRED_NOTICE, ephemeral=True) | ||
| return | ||
| await interaction.response.edit_message( | ||
| content=f"✅ You answered: {option}", view=None | ||
| ) | ||
|
Comment on lines
+60
to
+62
Contributor
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 47733 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- discord choice_ui.py ---'
sed -n '1,130p' autogpt_platform/backend/backend/copilot/bot/adapters/discord/choice_ui.py
printf '%s\n' '--- discord adapter choice flow ---'
sed -n '170,225p' autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py
printf '%s\n' '--- slack adapter acknowledgement and choice flow ---'
sed -n '240,305p' autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py
sed -n '585,635p' autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py
printf '%s\n' '--- teams adapter acknowledgement and choice flow ---'
sed -n '220,270p' autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py
sed -n '375,420p' autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py
printf '%s\n' '--- relevant symbols and tests ---'
rg -n -C 4 'resolve_choice|GETDEL|getdel|on_message|edit_message|chat_update|_post\\(' autogpt_platform/backend/backend/copilot/bot/adapters autogpt_platform/backend/backend/copilot -g '*.py' | head -n 400Repository: Significant-Gravitas/AutoGPT Length of output: 13947 🏁 Script executed: #!/bin/bash
set -eu
sed -n '1,130p' autogpt_platform/backend/backend/copilot/bot/adapters/discord/choice_ui.py
sed -n '170,225p' autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py
sed -n '240,305p' autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py
sed -n '585,635p' autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py
sed -n '220,270p' autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py
sed -n '375,420p' autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py
rg -n -C 3 'resolve_choice|GETDEL|getdel|on_message|edit_message|chat_update|_post\(' autogpt_platform/backend/backend/copilot/bot/adapters -g '*.py' | head -n 400Repository: Significant-Gravitas/AutoGPT Length of output: 50384 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- choice token implementation ---'
fd -i 'choices*.py' autogpt_platform/backend/backend/copilot
choice_file=$(fd -i 'choices*.py' autogpt_platform/backend/backend/copilot | head -n 1)
sed -n '1,220p' "$choice_file"
printf '%s\n' '--- focused adapter tests ---'
sed -n '1,180p' autogpt_platform/backend/backend/copilot/bot/adapters/discord/choice_ui_test.py
sed -n '480,555p' autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter_test.py
fd -i 'adapter*test.py' autogpt_platform/backend/backend/copilot/bot/adapters/teams
rg -n -C 5 '_dispatch_choice_click|resolve_choice|You answered|choice' autogpt_platform/backend/backend/copilot/bot/adapters/teams -g '*test.py'Repository: Significant-Gravitas/AutoGPT Length of output: 23149 Continue dispatch after a choice acknowledgement failure. After 📍 Affects 3 files
🤖 Prompt for AI Agents |
||
| ctx = _context_from_interaction(interaction, option) | ||
| if ctx is not None: | ||
| await self._on_message(ctx, self._adapter) | ||
|
|
||
|
|
||
| def _context_from_interaction( | ||
| interaction: discord.Interaction, option: str | ||
| ) -> MessageContext | None: | ||
| if interaction.channel_id is None or interaction.user is None: | ||
| return None | ||
| channel_type: ChannelType = "channel" | ||
| if interaction.guild_id is None: | ||
| channel_type = "dm" | ||
| elif isinstance(interaction.channel, discord.Thread): | ||
| channel_type = "thread" | ||
| return MessageContext( | ||
| platform="discord", | ||
| channel_type=channel_type, | ||
| server_id=str(interaction.guild_id) if interaction.guild_id else None, | ||
| channel_id=str(interaction.channel_id), | ||
| message_id=str(interaction.id), | ||
| user_id=str(interaction.user.id), | ||
| username=interaction.user.display_name, | ||
| text=option, | ||
| bot_mentioned=True, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| """Tests for Discord native choice buttons (SECRT-2604).""" | ||
|
|
||
| from unittest.mock import AsyncMock, MagicMock, patch | ||
|
|
||
| import discord | ||
| import pytest | ||
|
|
||
| from .choice_ui import build_choice_view | ||
|
|
||
| _CHOICES = "backend.copilot.bot.adapters.discord.choice_ui.choices" | ||
|
|
||
|
|
||
| def _interaction( | ||
| *, channel_id: int = 111, guild_id: int | None = 222, user_id: int = 9 | ||
| ) -> MagicMock: | ||
| interaction = MagicMock() | ||
| interaction.channel_id = channel_id | ||
| interaction.guild_id = guild_id | ||
| interaction.channel = MagicMock(spec=discord.TextChannel) | ||
| interaction.id = 555 | ||
| interaction.user = MagicMock() | ||
| interaction.user.id = user_id | ||
| interaction.user.display_name = "Bently" | ||
| interaction.response = MagicMock() | ||
| interaction.response.send_message = AsyncMock() | ||
| interaction.response.edit_message = AsyncMock() | ||
| return interaction | ||
|
|
||
|
|
||
| class TestBuildChoiceView: | ||
| @pytest.mark.asyncio | ||
| async def test_one_button_per_option(self): | ||
| adapter = MagicMock() | ||
| on_message = AsyncMock() | ||
| view = build_choice_view(adapter, on_message, "tok", ["US", "EU", "AP"]) | ||
| assert len(view.children) == 3 | ||
| assert [b.label for b in view.children] == ["US", "EU", "AP"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_labels_truncate_to_discord_button_cap(self): | ||
| adapter = MagicMock() | ||
| on_message = AsyncMock() | ||
| long_label = "x" * 200 | ||
| view = build_choice_view(adapter, on_message, "tok", [long_label]) | ||
| assert len(view.children[0].label) == 80 | ||
|
|
||
|
|
||
| class TestChoiceButtonCallback: | ||
| @pytest.mark.asyncio | ||
| async def test_click_resolves_and_dispatches_as_message(self): | ||
| adapter = MagicMock() | ||
| on_message = AsyncMock() | ||
| interaction = _interaction() | ||
|
|
||
| with patch(f"{_CHOICES}.resolve_choice", new=AsyncMock(return_value="EU")): | ||
| view = build_choice_view(adapter, on_message, "tok", ["US", "EU"]) | ||
| await view.children[1].callback(interaction) | ||
|
|
||
| interaction.response.edit_message.assert_awaited_once_with( | ||
| content="✅ You answered: EU", view=None | ||
| ) | ||
| on_message.assert_awaited_once() | ||
| ctx, dispatched_adapter = on_message.await_args.args | ||
| assert dispatched_adapter is adapter | ||
| assert ctx.text == "EU" | ||
| assert ctx.platform == "discord" | ||
| assert ctx.channel_id == "111" | ||
| assert ctx.user_id == "9" | ||
| assert ctx.username == "Bently" | ||
| assert ctx.channel_type == "channel" | ||
| assert ctx.bot_mentioned is True | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_dm_interaction_maps_to_dm_channel_type(self): | ||
| adapter = MagicMock() | ||
| on_message = AsyncMock() | ||
| interaction = _interaction(guild_id=None) | ||
|
|
||
| with patch(f"{_CHOICES}.resolve_choice", new=AsyncMock(return_value="US")): | ||
| view = build_choice_view(adapter, on_message, "tok", ["US"]) | ||
| await view.children[0].callback(interaction) | ||
|
|
||
| ctx, _ = on_message.await_args.args | ||
| assert ctx.channel_type == "dm" | ||
| assert ctx.server_id is None | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_expired_token_shows_ephemeral_notice_and_does_not_dispatch(self): | ||
| adapter = MagicMock() | ||
| on_message = AsyncMock() | ||
| interaction = _interaction() | ||
|
|
||
| with patch(f"{_CHOICES}.resolve_choice", new=AsyncMock(return_value=None)): | ||
| view = build_choice_view(adapter, on_message, "tok", ["US", "EU"]) | ||
| await view.children[0].callback(interaction) | ||
|
|
||
| interaction.response.send_message.assert_awaited_once() | ||
| assert "expired" in interaction.response.send_message.await_args.args[0] | ||
| interaction.response.edit_message.assert_not_awaited() | ||
| on_message.assert_not_awaited() |
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Return the text fallback when a native label would be clipped.
The native widgets clip labels to 80, 75, 60, and 64 characters. Two options with the same visible prefix can become indistinguishable, but clicking either dispatches its full original text. Return
Falsebefore native delivery when any option exceeds that platform limit.TurnStreamerwill then clear the token and send the full numbered-text fallback.autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py#L207-L209: reject options longer than the Discord button-label limit before building the view.autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py#L614-L619: reject options longer than the Slack Block Kit button-label limit before posting blocks.autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py#L402-L406: reject options longer than the Adaptive Card action-title limit before posting the card.autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py#L455-L462: reject options longer than the Telegram inline-button label limit before posting the keyboard.📍 Affects 4 files
autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py#L207-L209(this comment)autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py#L614-L619autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py#L402-L406autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py#L455-L462🤖 Prompt for AI Agents