From 2c00393092c4d0dc9e4fa72501bae02a0d0f8b36 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 02:53:19 +0000 Subject: [PATCH 1/2] feat(externalselect): add initialOption + option_groups (vercel/chat#410, #397) Ports two upstream PRs that together complete ExternalSelect support: - vercel/chat#397 introduced ExternalSelectElement and the block_suggestion / onOptionsLoad runtime; the runtime half landed here in #66 but the modal element type was deferred. This PR adds the missing ExternalSelectElement TypedDict + ExternalSelect builder and wires up _external_select_to_block in the Slack modal renderer. - vercel/chat#410 adds two new optional fields on top: initialOption (pre-selected option object) and option_groups (labeled sections, Slack max 100 groups x 100 options, label max 75 chars). The handler return type widens to OptionsLoadResult = list[options] | list[OptionsLoadGroup]; the Slack adapter detects grouped form by the presence of an "options" key on the first entry and emits Slack's option_groups response (mutually exclusive with options per Slack's spec). Hazard #1 (truthiness): min_query_length=0 is preserved (0 means "fire on every keystroke"); not silently dropped by an `or` fallback. Hazard #7 (omit vs None): unset initial_option / placeholder / min_query_length are omitted from the rendered Block Kit element, not serialized as null. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --- src/chat_sdk/__init__.py | 10 ++ src/chat_sdk/adapters/slack/adapter.py | 72 +++++++-- src/chat_sdk/adapters/slack/modals.py | 49 ++++++ src/chat_sdk/chat.py | 6 +- src/chat_sdk/modals.py | 64 +++++++- src/chat_sdk/types.py | 9 +- tests/test_chat_faithful.py | 46 ++++++ tests/test_modals.py | 56 ++++++- tests/test_options_load.py | 216 +++++++++++++++++++++++++ tests/test_slack_modals.py | 130 +++++++++++++++ 10 files changed, 637 insertions(+), 21 deletions(-) diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 0e42337..4a30f52 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -67,9 +67,12 @@ from chat_sdk.logger import ConsoleLogger, Logger, LogLevel from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig from chat_sdk.modals import ( + ExternalSelect, + ExternalSelectElement, Modal, ModalChild, ModalElement, + OptionsLoadGroup, RadioSelect, RadioSelectElement, Select, @@ -78,6 +81,7 @@ SelectOptionElement, TextInput, TextInputElement, + external_select, filter_modal_children, is_modal_element, modal, @@ -158,6 +162,7 @@ ModalResponse, ModalSubmitEvent, OptionsLoadEvent, + OptionsLoadResult, PlanUpdateChunk, Postable, PostableAst, @@ -296,6 +301,7 @@ "MessageHistoryCache", "MessageHistoryConfig", # Modal builders (PascalCase primary — matches source TS SDK) + "ExternalSelect", "Modal", "RadioSelect", "Select", @@ -305,11 +311,14 @@ "modal", "text_input", "select", + "external_select", "select_option", "radio_select", # Modal types + "ExternalSelectElement", "ModalChild", "ModalElement", + "OptionsLoadGroup", "RadioSelectElement", "SelectElement", "SelectOptionElement", @@ -363,6 +372,7 @@ "ModalSubmitEvent", "OptionsLoadEvent", "OptionsLoadHandler", + "OptionsLoadResult", "PlanUpdateChunk", "Postable", "PostableAst", diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 7fc74cf..1958c59 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -52,7 +52,7 @@ ) from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack from chat_sdk.logger import ConsoleLogger, Logger -from chat_sdk.modals import ModalElement, SelectOptionElement +from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement from chat_sdk.shared.adapter_utils import extract_card, extract_files from chat_sdk.shared.errors import AdapterRateLimitError, AuthenticationError, ValidationError from chat_sdk.types import ( @@ -1098,24 +1098,70 @@ def _late_error(t: asyncio.Task[Any]) -> None: return self._options_load_response(result if result is not None else []) - def _options_load_response(self, options_list: list[SelectOptionElement]) -> dict[str, Any]: - """Serialize ``SelectOptionElement`` entries to a Slack JSON response.""" - slack_options: list[dict[str, Any]] = [] - for opt in options_list[:100]: - entry: dict[str, Any] = { - "text": {"type": "plain_text", "text": opt.get("label", "")}, - "value": opt.get("value", ""), + def _options_load_response( + self, + result: list[SelectOptionElement] | list[OptionsLoadGroup], + ) -> dict[str, Any]: + """Serialize a flat option list or grouped option list to a Slack JSON response. + + Mirrors upstream ``optionsLoadResponse``: when the first entry has an + ``options`` key it is treated as a list of :class:`OptionsLoadGroup` + and rendered as ``option_groups``; otherwise it's a flat list of + :class:`SelectOptionElement` rendered as ``options``. Slack's spec is + explicit that the two are mutually exclusive (only one may appear in + the response body). + """ + # Detect grouped form (TS: ``"options" in result[0]``). A grouped + # entry is a dict with an ``options`` list inside it; a flat entry is + # a dict with ``label``/``value`` keys. + is_groups = ( + len(result) > 0 + and isinstance(result[0], dict) + and "options" in result[0] + and isinstance(result[0].get("options"), list) + ) + + if is_groups: + groups_in = cast("list[OptionsLoadGroup]", result)[:100] + slack_groups: list[dict[str, Any]] = [] + for group in groups_in: + group_options = group.get("options", [])[:100] + slack_groups.append( + { + # Slack spec: group label is plain_text, max 75 chars. + "label": {"type": "plain_text", "text": group.get("label", "")[:75]}, + "options": [self._select_option_to_slack(opt) for opt in group_options], + } + ) + return { + "body": json.dumps({"option_groups": slack_groups}), + "status": 200, + "headers": {"Content-Type": "application/json"}, } - desc = opt.get("description") - if desc: - entry["description"] = {"type": "plain_text", "text": desc} - slack_options.append(entry) + + flat = cast("list[SelectOptionElement]", result)[:100] return { - "body": json.dumps({"options": slack_options}), + "body": json.dumps({"options": [self._select_option_to_slack(opt) for opt in flat]}), "status": 200, "headers": {"Content-Type": "application/json"}, } + @staticmethod + def _select_option_to_slack(opt: SelectOptionElement) -> dict[str, Any]: + """Convert a :class:`SelectOptionElement` to Slack's option object shape. + + Mirrors upstream ``selectOptionToSlackOption`` — the ``description`` + key is omitted (not set to ``null``) when not provided. + """ + entry: dict[str, Any] = { + "text": {"type": "plain_text", "text": opt.get("label", "")}, + "value": opt.get("value", ""), + } + desc = opt.get("description") + if desc: + entry["description"] = {"type": "plain_text", "text": desc} + return entry + # ================================================================== # View submission / close # ================================================================== diff --git a/src/chat_sdk/adapters/slack/modals.py b/src/chat_sdk/adapters/slack/modals.py index ae5c566..29859d6 100644 --- a/src/chat_sdk/adapters/slack/modals.py +++ b/src/chat_sdk/adapters/slack/modals.py @@ -102,6 +102,8 @@ def _modal_child_to_block(child: ModalChild) -> SlackBlock: return _text_input_to_block(child) # type: ignore[arg-type] if child_type == "select": return _select_to_block(child) # type: ignore[arg-type] + if child_type == "external_select": + return _external_select_to_block(child) # type: ignore[arg-type] if child_type == "radio_select": return _radio_select_to_block(child) # type: ignore[arg-type] if child_type == "text": @@ -179,6 +181,53 @@ def _select_to_block(select: dict[str, Any]) -> SlackBlock: } +def _external_select_to_block(select: dict[str, Any]) -> SlackBlock: + """Convert an :class:`ExternalSelectElement` to a Slack input block with external_select. + + Mirrors upstream ``externalSelectToBlock``. Options are loaded at runtime + by an ``onOptionsLoad`` handler — this just emits the placeholder element. + Optional fields (``placeholder``, ``min_query_length``, ``initial_option``) + are omitted from the output when not set, matching upstream behavior. + """ + element: dict[str, Any] = { + "type": "external_select", + "action_id": select.get("id", ""), + } + + placeholder = select.get("placeholder") + if placeholder: + element["placeholder"] = {"type": "plain_text", "text": placeholder} + + min_query_length = select.get("min_query_length") + # Use ``is not None`` (hazard #1): ``0`` is a valid Slack value meaning + # "fire on every keystroke" and must not silently fall back to omitting + # the key (which would default to Slack's 3-character minimum). + if min_query_length is not None: + element["min_query_length"] = min_query_length + + initial_option = select.get("initial_option") + if initial_option: + # Unlike static select, ``initial_option`` is the full + # ``{label, value}`` object — the loader hasn't run yet so a value + # string would be ambiguous. Mirrors selectOptionToSlackOption. + slack_initial: dict[str, Any] = { + "text": {"type": "plain_text", "text": initial_option.get("label", "")}, + "value": initial_option.get("value", ""), + } + desc = initial_option.get("description") + if desc: + slack_initial["description"] = {"type": "plain_text", "text": desc} + element["initial_option"] = slack_initial + + return { + "type": "input", + "block_id": select.get("id", ""), + "optional": select.get("optional", False), + "label": {"type": "plain_text", "text": select.get("label", "")}, + "element": element, + } + + def _radio_select_to_block(radio_select: dict[str, Any]) -> SlackBlock: """Convert a RadioSelectElement to a Slack input block with radio_buttons.""" limited_options = radio_select.get("options", [])[:10] diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 6911563..0ac255a 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -21,7 +21,6 @@ from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter from chat_sdk.errors import ChatError, LockError from chat_sdk.logger import ConsoleLogger, Logger -from chat_sdk.modals import SelectOptionElement from chat_sdk.thread import ( ThreadImpl, _active_chat, @@ -56,6 +55,7 @@ ModalSubmitEvent, OnLockConflict, OptionsLoadEvent, + OptionsLoadResult, QueueEntry, ReactionEvent, SlashCommandEvent, @@ -87,7 +87,7 @@ ActionHandler = Callable[[ActionEvent], Any] OptionsLoadHandler = Callable[ [OptionsLoadEvent], - Awaitable[list[SelectOptionElement] | None] | list[SelectOptionElement] | None, + Awaitable[OptionsLoadResult | None] | OptionsLoadResult | None, ] ModalSubmitHandler = Callable[[ModalSubmitEvent], Any] ModalCloseHandler = Callable[[ModalCloseEvent], Any] @@ -953,7 +953,7 @@ async def process_options_load( self, event: OptionsLoadEvent, options: WebhookOptions | None = None, # noqa: ARG002 (match upstream signature) - ) -> list[SelectOptionElement] | None: + ) -> OptionsLoadResult | None: """Process an options-load event (external-select suggestion lookup). Runs specific-action-ID handlers before catch-all handlers and returns diff --git a/src/chat_sdk/modals.py b/src/chat_sdk/modals.py index a305783..50ab6a6 100644 --- a/src/chat_sdk/modals.py +++ b/src/chat_sdk/modals.py @@ -52,8 +52,35 @@ class RadioSelectElement(TypedDict, total=False): optional: bool +class OptionsLoadGroup(TypedDict): + """A labeled group of options returned by an ``onOptionsLoad`` handler. + + Maps to upstream TS ``OptionsLoadGroup``. Slack ``external_select`` renders + grouped results as ``option_groups`` (mutually exclusive with top-level + ``options`` per Slack's spec). + """ + + label: str + options: list[SelectOptionElement] + + +class ExternalSelectElement(TypedDict, total=False): + """External select form element (loads options dynamically from a handler).""" + + type: str # "external_select" + id: str + label: str + placeholder: str + min_query_length: int + optional: bool + # Pre-selected option when the modal opens (must match an option returned + # by the loader). Unlike static :class:`SelectElement`, the initial value + # is the full ``{label, value}`` object since the loader has not run yet. + initial_option: SelectOptionElement + + # Union of all modal child types -ModalChild = TextInputElement | SelectElement | RadioSelectElement | TextElement | FieldsElement +ModalChild = TextInputElement | SelectElement | ExternalSelectElement | RadioSelectElement | TextElement | FieldsElement class ModalElement(TypedDict, total=False): @@ -69,7 +96,7 @@ class ModalElement(TypedDict, total=False): children: list[ModalChild] -VALID_MODAL_CHILD_TYPES = {"text_input", "select", "radio_select", "text", "fields"} +VALID_MODAL_CHILD_TYPES = {"text_input", "select", "external_select", "radio_select", "text", "fields"} def is_modal_element(value: Any) -> bool: @@ -180,6 +207,38 @@ def Select( return result +def ExternalSelect( + *, + id: str, + label: str, + placeholder: str | None = None, + min_query_length: int | None = None, + optional: bool | None = None, + initial_option: SelectOptionElement | None = None, +) -> ExternalSelectElement: + """Build an :class:`ExternalSelectElement` dict. + + Slack-only: renders to a Block Kit ``external_select`` element whose + options are populated by an :func:`Chat.on_options_load` handler at + runtime. ``initial_option`` is the full ``{label, value}`` object (the + loader hasn't run yet so just a value string would be ambiguous). + """ + result: ExternalSelectElement = { + "type": "external_select", + "id": id, + "label": label, + } + if placeholder is not None: + result["placeholder"] = placeholder + if min_query_length is not None: + result["min_query_length"] = min_query_length + if optional is not None: + result["optional"] = optional + if initial_option is not None: + result["initial_option"] = initial_option + return result + + def SelectOption( *, label: str, @@ -227,5 +286,6 @@ def RadioSelect( modal = Modal text_input = TextInput select = Select +external_select = ExternalSelect select_option = SelectOption radio_select = RadioSelect diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 6cdfe29..557cdda 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -19,7 +19,12 @@ from chat_sdk.cards import CardElement from chat_sdk.errors import ChatNotImplementedError from chat_sdk.logger import Logger, LogLevel -from chat_sdk.modals import SelectOptionElement +from chat_sdk.modals import OptionsLoadGroup, SelectOptionElement + +# A handler may return either a flat list of options or a list of labeled +# groups (Slack's ``option_groups`` shape). Mirrors upstream TS +# ``OptionsLoadResult = SelectOptionElement[] | OptionsLoadGroup[]``. +OptionsLoadResult = list[SelectOptionElement] | list[OptionsLoadGroup] def _parse_iso(s: str) -> datetime: @@ -1424,7 +1429,7 @@ def process_modal_submit( ) -> Awaitable[ModalResponse | None]: ... def process_options_load( self, event: OptionsLoadEvent, options: WebhookOptions | None = None - ) -> Awaitable[list[SelectOptionElement] | None]: ... + ) -> Awaitable[OptionsLoadResult | None]: ... def process_modal_close( self, event: Any, context_id: str | None = None, options: WebhookOptions | None = None ) -> None: ... diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 500970f..768a7de 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -1385,6 +1385,52 @@ async def _fallback(event: OptionsLoadEvent): for call in error_calls ) + # TS: "should support returning option groups" (vercel/chat#410) + async def test_should_support_returning_option_groups(self): + from chat_sdk.types import OptionsLoadEvent + + chat, adapter, state = await _init_chat() + + captured: list[OptionsLoadEvent] = [] + + async def _handler(event: OptionsLoadEvent): + captured.append(event) + return [ + { + "label": "Recent", + "options": [{"label": "Alice", "value": "u1"}], + }, + { + "label": "All", + "options": [ + {"label": "Bob", "value": "u2"}, + {"label": "Carol", "value": "u3"}, + ], + }, + ] + + chat.on_options_load("user_select", _handler) + + event = OptionsLoadEvent( + action_id="user_select", + query="", + user=_make_author(), + adapter=adapter, + raw={}, + ) + result = await chat.process_options_load(event) + + assert result is not None + assert len(result) == 2 + # First entry is the "Recent" group; mirrors the upstream assertion. + assert result[0]["label"] == "Recent" + # Round-trip: the handler-provided shape is returned verbatim — the + # adapter is responsible for translating to Slack's option_groups. + assert result[1]["options"] == [ + {"label": "Bob", "value": "u2"}, + {"label": "Carol", "value": "u3"}, + ] + # ============================================================================ # 13. openDM (tests 51-54) diff --git a/tests/test_modals.py b/tests/test_modals.py index fc49acf..f05fb5d 100644 --- a/tests/test_modals.py +++ b/tests/test_modals.py @@ -10,6 +10,7 @@ import pytest from chat_sdk.modals import ( + ExternalSelect, Modal, RadioSelect, Select, @@ -181,6 +182,58 @@ def test_select_omits_none(self): assert "optional" not in sel +# --------------------------------------------------------------------------- +# ExternalSelect builder +# --------------------------------------------------------------------------- + + +class TestExternalSelectBuilder: + # TS: "should create with required fields" + def test_create_with_required_fields(self): + es = ExternalSelect(id="person", label="Person") + assert es["type"] == "external_select" + assert es["id"] == "person" + assert es["label"] == "Person" + + # TS: "should include optional fields" + def test_include_optional_fields(self): + es = ExternalSelect( + id="person", + label="Person", + placeholder="Search people", + min_query_length=1, + optional=True, + ) + assert es["placeholder"] == "Search people" + assert es["min_query_length"] == 1 + assert es["optional"] is True + + def test_external_select_with_initial_option(self): + # Port of vercel/chat#410: ExternalSelect supports initialOption. + es = ExternalSelect( + id="person", + label="Person", + initial_option={"label": "Alice", "value": "u1"}, + ) + assert es["initial_option"] == {"label": "Alice", "value": "u1"} + + def test_external_select_omits_unset_fields(self): + # Hazard #7: omitted vs None — unset optional fields must not be + # serialized as ``"key": None``. When initial_option is None it + # must be absent from the dict. + es = ExternalSelect(id="person", label="Person") + assert "placeholder" not in es + assert "min_query_length" not in es + assert "optional" not in es + assert "initial_option" not in es + + def test_min_query_length_zero_preserved(self): + # Hazard #1: 0 is a meaningful Slack value ("fire on every keystroke") + # and must not be silently dropped by an ``or`` truthiness fallback. + es = ExternalSelect(id="person", label="Person", min_query_length=0) + assert es["min_query_length"] == 0 + + # --------------------------------------------------------------------------- # RadioSelect builder # --------------------------------------------------------------------------- @@ -252,12 +305,13 @@ def test_valid_children_pass_through(self): children = [ {"type": "text_input", "id": "a", "label": "A"}, {"type": "select", "id": "b", "label": "B", "options": []}, + {"type": "external_select", "id": "x", "label": "X"}, {"type": "radio_select", "id": "c", "label": "C", "options": []}, {"type": "text", "content": "hello"}, {"type": "fields", "items": []}, ] result = filter_modal_children(children) - assert len(result) == 5 + assert len(result) == 6 def test_filters_out_invalid_types(self): children = [ diff --git a/tests/test_options_load.py b/tests/test_options_load.py index edd0916..7ce712d 100644 --- a/tests/test_options_load.py +++ b/tests/test_options_load.py @@ -270,6 +270,222 @@ def _wait_until(awaitable: Any) -> None: # serverless runtime that rejects registration), the timeout branch # must still return the empty-options HTTP 200 fallback rather than # surfacing the exception to the webhook. + # TS: "handles block_suggestion with option_groups response" + # (vercel/chat#410, packages/adapter-slack/src/index.test.ts) + @pytest.mark.asyncio + async def test_handles_block_suggestion_with_option_groups_response(self): + chat = _make_mock_chat() + chat.process_options_load = AsyncMock( + return_value=[ + { + "label": "Recent", + "options": [{"label": "Alice", "value": "u1"}], + }, + { + "label": "All", + "options": [{"label": "Bob", "value": "u2"}], + }, + ] + ) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + parsed = json.loads(response["body"]) + assert parsed == { + "option_groups": [ + { + "label": {"type": "plain_text", "text": "Recent"}, + "options": [ + {"text": {"type": "plain_text", "text": "Alice"}, "value": "u1"}, + ], + }, + { + "label": {"type": "plain_text", "text": "All"}, + "options": [ + {"text": {"type": "plain_text", "text": "Bob"}, "value": "u2"}, + ], + }, + ] + } + + # Slack spec: option_groups and options are mutually exclusive in the + # response body. Verify the adapter never emits both. + @pytest.mark.asyncio + async def test_option_groups_and_options_are_mutually_exclusive(self): + chat = _make_mock_chat() + chat.process_options_load = AsyncMock( + return_value=[ + { + "label": "Recent", + "options": [{"label": "Alice", "value": "u1"}], + }, + ] + ) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + + parsed = json.loads(response["body"]) + # Slack rejects payloads that include both. The adapter must pick one. + assert "option_groups" in parsed + assert "options" not in parsed + + # Slack spec: group label is plain_text, max 75 chars. + @pytest.mark.asyncio + async def test_option_groups_label_truncated_to_75_chars(self): + chat = _make_mock_chat() + long_label = "x" * 200 + chat.process_options_load = AsyncMock( + return_value=[ + { + "label": long_label, + "options": [{"label": "A", "value": "a"}], + } + ] + ) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + + parsed = json.loads(response["body"]) + assert parsed["option_groups"][0]["label"]["text"] == "x" * 75 + + # Slack spec: max 100 groups, max 100 options per group. + @pytest.mark.asyncio + async def test_option_groups_limits_to_100_groups_and_100_options(self): + chat = _make_mock_chat() + # 150 groups, each with 150 options — both should be capped to 100. + chat.process_options_load = AsyncMock( + return_value=[ + { + "label": f"Group {i}", + "options": [{"label": f"opt-{i}-{j}", "value": f"v{i}-{j}"} for j in range(150)], + } + for i in range(150) + ] + ) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + parsed = json.loads(response["body"]) + assert len(parsed["option_groups"]) == 100 + for group in parsed["option_groups"]: + assert len(group["options"]) == 100 + + # An empty list — common when a handler returns "no results" — must + # render as ``{"options": []}``, not ``{"option_groups": []}``. Detection + # must read the first element's shape, not just the outer container. + @pytest.mark.asyncio + async def test_empty_result_renders_as_options_not_groups(self): + chat = _make_mock_chat() + chat.process_options_load = AsyncMock(return_value=[]) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + + parsed = json.loads(response["body"]) + assert parsed == {"options": []} + + # Per-option ``description`` should round-trip through both flat and + # grouped result shapes (covers the shared selectOptionToSlackOption + # helper extracted in vercel/chat#410). + @pytest.mark.asyncio + async def test_grouped_options_include_description(self): + chat = _make_mock_chat() + chat.process_options_load = AsyncMock( + return_value=[ + { + "label": "Recent", + "options": [ + {"label": "Alice", "value": "u1", "description": "Engineering"}, + ], + }, + ] + ) + + adapter = SlackAdapter(SlackAdapterConfig(signing_secret="test-signing-secret", bot_token="xoxb-test")) + await adapter.initialize(chat) + + payload = { + "type": "block_suggestion", + "team": {"id": "T123"}, + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "action_id": "user_select", + "block_id": "user_block", + "value": "", + } + req = _make_interactive_request(payload) + + response = await adapter.handle_webhook(req) + parsed = json.loads(response["body"]) + assert parsed["option_groups"][0]["options"][0] == { + "text": {"type": "plain_text", "text": "Alice"}, + "value": "u1", + "description": {"type": "plain_text", "text": "Engineering"}, + } + @pytest.mark.asyncio async def test_timeout_falls_back_when_wait_until_raises(self): from chat_sdk.types import WebhookOptions diff --git a/tests/test_slack_modals.py b/tests/test_slack_modals.py index 6da68fb..bf0cdcc 100644 --- a/tests/test_slack_modals.py +++ b/tests/test_slack_modals.py @@ -14,6 +14,7 @@ modal_to_slack_view, ) from chat_sdk.modals import ( + ExternalSelectElement, ModalElement, RadioSelectElement, SelectElement, @@ -117,6 +118,27 @@ def _option(*, label: str, value: str, description: str | None = None) -> Select return opt +def _external_select( + *, + id: str, + label: str, + placeholder: str | None = None, + min_query_length: int | None = None, + optional: bool = False, + initial_option: SelectOptionElement | None = None, +) -> ExternalSelectElement: + el: ExternalSelectElement = {"type": "external_select", "id": id, "label": label} + if placeholder: + el["placeholder"] = placeholder + if min_query_length is not None: + el["min_query_length"] = min_query_length + if optional: + el["optional"] = True + if initial_option is not None: + el["initial_option"] = initial_option + return el + + # --------------------------------------------------------------------------- # modalToSlackView # --------------------------------------------------------------------------- @@ -479,3 +501,111 @@ def test_radio_description_mrkdwn(self): el = view["blocks"][0]["element"] assert el["options"][0]["description"] == {"type": "mrkdwn", "text": "For *individuals*"} assert el["options"][1]["description"] == {"type": "mrkdwn", "text": "For _teams_"} + + +# --------------------------------------------------------------------------- +# ExternalSelect in modals +# +# Ports the two upstream tests in +# packages/adapter-slack/src/modals.test.ts (vercel/chat#397, #410): +# - "converts external select with placeholder and min query length" +# - "converts external select with initialOption" +# Plus Python-specific coverage for the omit-None hazard (#7) and the +# 0-as-min-query-length truthiness hazard (#1). +# --------------------------------------------------------------------------- + + +class TestModalExternalSelect: + # TS: "converts external select with placeholder and min query length" + def test_converts_external_select_with_placeholder_and_min_query_length(self): + modal = _modal( + children=[ + _external_select( + id="person", + label="Person", + placeholder="Search people", + min_query_length=1, + ), + ] + ) + view = modal_to_slack_view(modal) + block = view["blocks"][0] + assert block["type"] == "input" + assert block["block_id"] == "person" + assert block["label"] == {"type": "plain_text", "text": "Person"} + el = block["element"] + assert el["type"] == "external_select" + assert el["action_id"] == "person" + assert el["placeholder"] == {"type": "plain_text", "text": "Search people"} + assert el["min_query_length"] == 1 + + # TS: "converts external select with initialOption" + def test_converts_external_select_with_initial_option(self): + modal = _modal( + children=[ + _external_select( + id="person", + label="Person", + initial_option={"label": "Alice", "value": "u1"}, + ), + ] + ) + view = modal_to_slack_view(modal) + block = view["blocks"][0] + assert block["type"] == "input" + assert block["block_id"] == "person" + el = block["element"] + assert el["type"] == "external_select" + assert el["action_id"] == "person" + assert el["initial_option"] == { + "text": {"type": "plain_text", "text": "Alice"}, + "value": "u1", + } + + def test_external_select_omits_initial_option_when_unset(self): + # Hazard #7: when initial_option is unset, the rendered Block Kit + # element must not contain the key at all (Slack treats null + # differently than missing). + modal = _modal(children=[_external_select(id="person", label="Person")]) + view = modal_to_slack_view(modal) + el = view["blocks"][0]["element"] + assert "initial_option" not in el + assert "placeholder" not in el + assert "min_query_length" not in el + + def test_external_select_min_query_length_zero_preserved(self): + # Hazard #1: a min_query_length of 0 is meaningful (fire on every + # keystroke). It must not be silently omitted by a truthiness check. + modal = _modal(children=[_external_select(id="person", label="Person", min_query_length=0)]) + view = modal_to_slack_view(modal) + el = view["blocks"][0]["element"] + assert el["min_query_length"] == 0 + + def test_external_select_initial_option_with_description(self): + # The full SelectOptionElement shape (including description) should + # round-trip into the rendered initial_option. + modal = _modal( + children=[ + _external_select( + id="person", + label="Person", + initial_option={ + "label": "Alice", + "value": "u1", + "description": "Engineering", + }, + ), + ] + ) + view = modal_to_slack_view(modal) + el = view["blocks"][0]["element"] + assert el["initial_option"] == { + "text": {"type": "plain_text", "text": "Alice"}, + "value": "u1", + "description": {"type": "plain_text", "text": "Engineering"}, + } + + def test_external_select_optional(self): + modal = _modal(children=[_external_select(id="person", label="Person", optional=True)]) + view = modal_to_slack_view(modal) + assert view["blocks"][0]["optional"] is True From 99a52a4c3a8fe94f15a5b2dbffd2df41bedadb94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 19:58:09 +0000 Subject: [PATCH 2/2] fix(slack): use is-not-None guard for initial_option in external_select renderer Address review on PR #84 (modals.py:209). The TS expression ``if (select.initialOption)`` only filters null/undefined since ``{}`` is truthy in JS. Python ``if initial_option:`` falsely drops a hand-constructed ``initial_option={}`` because empty dicts are falsy. Switch to ``is not None`` for parity with TS and consistency with the ``min_query_length is not None`` check three lines above. Adds test_external_select_initial_option_empty_dict_renders regression test that fails before the fix. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --- src/chat_sdk/adapters/slack/modals.py | 7 +++++- tests/test_slack_modals.py | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/chat_sdk/adapters/slack/modals.py b/src/chat_sdk/adapters/slack/modals.py index 29859d6..bc597b9 100644 --- a/src/chat_sdk/adapters/slack/modals.py +++ b/src/chat_sdk/adapters/slack/modals.py @@ -206,7 +206,12 @@ def _external_select_to_block(select: dict[str, Any]) -> SlackBlock: element["min_query_length"] = min_query_length initial_option = select.get("initial_option") - if initial_option: + if initial_option is not None: + # Hazard #1: ``is not None`` (not truthiness) so a hand-constructed + # empty dict ``{}`` doesn't silently render as no initial_option, + # matching the TS ``if (select.initialOption)`` semantics where + # ``{}`` is truthy. Also keeps consistency with the + # ``min_query_length is not None`` check above. # Unlike static select, ``initial_option`` is the full # ``{label, value}`` object — the loader hasn't run yet so a value # string would be ambiguous. Mirrors selectOptionToSlackOption. diff --git a/tests/test_slack_modals.py b/tests/test_slack_modals.py index bf0cdcc..3808380 100644 --- a/tests/test_slack_modals.py +++ b/tests/test_slack_modals.py @@ -573,6 +573,37 @@ def test_external_select_omits_initial_option_when_unset(self): assert "placeholder" not in el assert "min_query_length" not in el + def test_external_select_initial_option_empty_dict_renders(self): + """A hand-built ``initial_option={}`` must round-trip, not silently drop. + + What to fix if this fails: in + ``src/chat_sdk/adapters/slack/modals.py``, + ``_external_select_to_block`` must guard with ``initial_option is not None``, + not the truthiness check ``if initial_option:``. The TS expression + ``if (select.initialOption)`` only filters null/undefined; an empty + object literal is truthy in JS but ``{}`` is falsy in Python. + Without ``is not None``, hand-constructed empty dicts disappear from + the rendered Block Kit. + """ + external_select_dict = { + "type": "external_select", + "id": "person", + "label": "Person", + "initial_option": {}, # hand-built empty dict + } + modal = _modal(children=[external_select_dict]) + view = modal_to_slack_view(modal) + el = view["blocks"][0]["element"] + assert "initial_option" in el, ( + "_external_select_to_block dropped initial_option={}; use 'is not None' instead of truthiness in modals.py" + ) + # Renders with empty label/value (since the input is empty), but + # the element is present — that's the parity guarantee we want. + assert el["initial_option"] == { + "text": {"type": "plain_text", "text": ""}, + "value": "", + } + def test_external_select_min_query_length_zero_preserved(self): # Hazard #1: a min_query_length of 0 is meaningful (fire on every # keystroke). It must not be silently omitted by a truthiness check.