Skip to content

Commit a3133c7

Browse files
feat(externalselect): add initialOption + option_groups (vercel/chat#410, #397) (#84)
* 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 * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04c0658 commit a3133c7

10 files changed

Lines changed: 673 additions & 21 deletions

File tree

src/chat_sdk/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@
6767
from chat_sdk.logger import ConsoleLogger, Logger, LogLevel
6868
from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig
6969
from chat_sdk.modals import (
70+
ExternalSelect,
71+
ExternalSelectElement,
7072
Modal,
7173
ModalChild,
7274
ModalElement,
75+
OptionsLoadGroup,
7376
RadioSelect,
7477
RadioSelectElement,
7578
Select,
@@ -78,6 +81,7 @@
7881
SelectOptionElement,
7982
TextInput,
8083
TextInputElement,
84+
external_select,
8185
filter_modal_children,
8286
is_modal_element,
8387
modal,
@@ -158,6 +162,7 @@
158162
ModalResponse,
159163
ModalSubmitEvent,
160164
OptionsLoadEvent,
165+
OptionsLoadResult,
161166
PlanUpdateChunk,
162167
Postable,
163168
PostableAst,
@@ -296,6 +301,7 @@
296301
"MessageHistoryCache",
297302
"MessageHistoryConfig",
298303
# Modal builders (PascalCase primary — matches source TS SDK)
304+
"ExternalSelect",
299305
"Modal",
300306
"RadioSelect",
301307
"Select",
@@ -305,11 +311,14 @@
305311
"modal",
306312
"text_input",
307313
"select",
314+
"external_select",
308315
"select_option",
309316
"radio_select",
310317
# Modal types
318+
"ExternalSelectElement",
311319
"ModalChild",
312320
"ModalElement",
321+
"OptionsLoadGroup",
313322
"RadioSelectElement",
314323
"SelectElement",
315324
"SelectOptionElement",
@@ -363,6 +372,7 @@
363372
"ModalSubmitEvent",
364373
"OptionsLoadEvent",
365374
"OptionsLoadHandler",
375+
"OptionsLoadResult",
366376
"PlanUpdateChunk",
367377
"Postable",
368378
"PostableAst",

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
)
5353
from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack
5454
from chat_sdk.logger import ConsoleLogger, Logger
55-
from chat_sdk.modals import ModalElement, SelectOptionElement
55+
from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement
5656
from chat_sdk.shared.adapter_utils import extract_card, extract_files
5757
from chat_sdk.shared.errors import AdapterRateLimitError, AuthenticationError, ValidationError
5858
from chat_sdk.types import (
@@ -1098,24 +1098,70 @@ def _late_error(t: asyncio.Task[Any]) -> None:
10981098

10991099
return self._options_load_response(result if result is not None else [])
11001100

1101-
def _options_load_response(self, options_list: list[SelectOptionElement]) -> dict[str, Any]:
1102-
"""Serialize ``SelectOptionElement`` entries to a Slack JSON response."""
1103-
slack_options: list[dict[str, Any]] = []
1104-
for opt in options_list[:100]:
1105-
entry: dict[str, Any] = {
1106-
"text": {"type": "plain_text", "text": opt.get("label", "")},
1107-
"value": opt.get("value", ""),
1101+
def _options_load_response(
1102+
self,
1103+
result: list[SelectOptionElement] | list[OptionsLoadGroup],
1104+
) -> dict[str, Any]:
1105+
"""Serialize a flat option list or grouped option list to a Slack JSON response.
1106+
1107+
Mirrors upstream ``optionsLoadResponse``: when the first entry has an
1108+
``options`` key it is treated as a list of :class:`OptionsLoadGroup`
1109+
and rendered as ``option_groups``; otherwise it's a flat list of
1110+
:class:`SelectOptionElement` rendered as ``options``. Slack's spec is
1111+
explicit that the two are mutually exclusive (only one may appear in
1112+
the response body).
1113+
"""
1114+
# Detect grouped form (TS: ``"options" in result[0]``). A grouped
1115+
# entry is a dict with an ``options`` list inside it; a flat entry is
1116+
# a dict with ``label``/``value`` keys.
1117+
is_groups = (
1118+
len(result) > 0
1119+
and isinstance(result[0], dict)
1120+
and "options" in result[0]
1121+
and isinstance(result[0].get("options"), list)
1122+
)
1123+
1124+
if is_groups:
1125+
groups_in = cast("list[OptionsLoadGroup]", result)[:100]
1126+
slack_groups: list[dict[str, Any]] = []
1127+
for group in groups_in:
1128+
group_options = group.get("options", [])[:100]
1129+
slack_groups.append(
1130+
{
1131+
# Slack spec: group label is plain_text, max 75 chars.
1132+
"label": {"type": "plain_text", "text": group.get("label", "")[:75]},
1133+
"options": [self._select_option_to_slack(opt) for opt in group_options],
1134+
}
1135+
)
1136+
return {
1137+
"body": json.dumps({"option_groups": slack_groups}),
1138+
"status": 200,
1139+
"headers": {"Content-Type": "application/json"},
11081140
}
1109-
desc = opt.get("description")
1110-
if desc:
1111-
entry["description"] = {"type": "plain_text", "text": desc}
1112-
slack_options.append(entry)
1141+
1142+
flat = cast("list[SelectOptionElement]", result)[:100]
11131143
return {
1114-
"body": json.dumps({"options": slack_options}),
1144+
"body": json.dumps({"options": [self._select_option_to_slack(opt) for opt in flat]}),
11151145
"status": 200,
11161146
"headers": {"Content-Type": "application/json"},
11171147
}
11181148

1149+
@staticmethod
1150+
def _select_option_to_slack(opt: SelectOptionElement) -> dict[str, Any]:
1151+
"""Convert a :class:`SelectOptionElement` to Slack's option object shape.
1152+
1153+
Mirrors upstream ``selectOptionToSlackOption`` — the ``description``
1154+
key is omitted (not set to ``null``) when not provided.
1155+
"""
1156+
entry: dict[str, Any] = {
1157+
"text": {"type": "plain_text", "text": opt.get("label", "")},
1158+
"value": opt.get("value", ""),
1159+
}
1160+
desc = opt.get("description")
1161+
if desc:
1162+
entry["description"] = {"type": "plain_text", "text": desc}
1163+
return entry
1164+
11191165
# ==================================================================
11201166
# View submission / close
11211167
# ==================================================================

src/chat_sdk/adapters/slack/modals.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def _modal_child_to_block(child: ModalChild) -> SlackBlock:
102102
return _text_input_to_block(child) # type: ignore[arg-type]
103103
if child_type == "select":
104104
return _select_to_block(child) # type: ignore[arg-type]
105+
if child_type == "external_select":
106+
return _external_select_to_block(child) # type: ignore[arg-type]
105107
if child_type == "radio_select":
106108
return _radio_select_to_block(child) # type: ignore[arg-type]
107109
if child_type == "text":
@@ -179,6 +181,58 @@ def _select_to_block(select: dict[str, Any]) -> SlackBlock:
179181
}
180182

181183

184+
def _external_select_to_block(select: dict[str, Any]) -> SlackBlock:
185+
"""Convert an :class:`ExternalSelectElement` to a Slack input block with external_select.
186+
187+
Mirrors upstream ``externalSelectToBlock``. Options are loaded at runtime
188+
by an ``onOptionsLoad`` handler — this just emits the placeholder element.
189+
Optional fields (``placeholder``, ``min_query_length``, ``initial_option``)
190+
are omitted from the output when not set, matching upstream behavior.
191+
"""
192+
element: dict[str, Any] = {
193+
"type": "external_select",
194+
"action_id": select.get("id", ""),
195+
}
196+
197+
placeholder = select.get("placeholder")
198+
if placeholder:
199+
element["placeholder"] = {"type": "plain_text", "text": placeholder}
200+
201+
min_query_length = select.get("min_query_length")
202+
# Use ``is not None`` (hazard #1): ``0`` is a valid Slack value meaning
203+
# "fire on every keystroke" and must not silently fall back to omitting
204+
# the key (which would default to Slack's 3-character minimum).
205+
if min_query_length is not None:
206+
element["min_query_length"] = min_query_length
207+
208+
initial_option = select.get("initial_option")
209+
if initial_option is not None:
210+
# Hazard #1: ``is not None`` (not truthiness) so a hand-constructed
211+
# empty dict ``{}`` doesn't silently render as no initial_option,
212+
# matching the TS ``if (select.initialOption)`` semantics where
213+
# ``{}`` is truthy. Also keeps consistency with the
214+
# ``min_query_length is not None`` check above.
215+
# Unlike static select, ``initial_option`` is the full
216+
# ``{label, value}`` object — the loader hasn't run yet so a value
217+
# string would be ambiguous. Mirrors selectOptionToSlackOption.
218+
slack_initial: dict[str, Any] = {
219+
"text": {"type": "plain_text", "text": initial_option.get("label", "")},
220+
"value": initial_option.get("value", ""),
221+
}
222+
desc = initial_option.get("description")
223+
if desc:
224+
slack_initial["description"] = {"type": "plain_text", "text": desc}
225+
element["initial_option"] = slack_initial
226+
227+
return {
228+
"type": "input",
229+
"block_id": select.get("id", ""),
230+
"optional": select.get("optional", False),
231+
"label": {"type": "plain_text", "text": select.get("label", "")},
232+
"element": element,
233+
}
234+
235+
182236
def _radio_select_to_block(radio_select: dict[str, Any]) -> SlackBlock:
183237
"""Convert a RadioSelectElement to a Slack input block with radio_buttons."""
184238
limited_options = radio_select.get("options", [])[:10]

src/chat_sdk/chat.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter
2222
from chat_sdk.errors import ChatError, LockError
2323
from chat_sdk.logger import ConsoleLogger, Logger
24-
from chat_sdk.modals import SelectOptionElement
2524
from chat_sdk.thread import (
2625
ThreadImpl,
2726
_active_chat,
@@ -56,6 +55,7 @@
5655
ModalSubmitEvent,
5756
OnLockConflict,
5857
OptionsLoadEvent,
58+
OptionsLoadResult,
5959
QueueEntry,
6060
ReactionEvent,
6161
SlashCommandEvent,
@@ -87,7 +87,7 @@
8787
ActionHandler = Callable[[ActionEvent], Any]
8888
OptionsLoadHandler = Callable[
8989
[OptionsLoadEvent],
90-
Awaitable[list[SelectOptionElement] | None] | list[SelectOptionElement] | None,
90+
Awaitable[OptionsLoadResult | None] | OptionsLoadResult | None,
9191
]
9292
ModalSubmitHandler = Callable[[ModalSubmitEvent], Any]
9393
ModalCloseHandler = Callable[[ModalCloseEvent], Any]
@@ -953,7 +953,7 @@ async def process_options_load(
953953
self,
954954
event: OptionsLoadEvent,
955955
options: WebhookOptions | None = None, # noqa: ARG002 (match upstream signature)
956-
) -> list[SelectOptionElement] | None:
956+
) -> OptionsLoadResult | None:
957957
"""Process an options-load event (external-select suggestion lookup).
958958
959959
Runs specific-action-ID handlers before catch-all handlers and returns

src/chat_sdk/modals.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,35 @@ class RadioSelectElement(TypedDict, total=False):
5252
optional: bool
5353

5454

55+
class OptionsLoadGroup(TypedDict):
56+
"""A labeled group of options returned by an ``onOptionsLoad`` handler.
57+
58+
Maps to upstream TS ``OptionsLoadGroup``. Slack ``external_select`` renders
59+
grouped results as ``option_groups`` (mutually exclusive with top-level
60+
``options`` per Slack's spec).
61+
"""
62+
63+
label: str
64+
options: list[SelectOptionElement]
65+
66+
67+
class ExternalSelectElement(TypedDict, total=False):
68+
"""External select form element (loads options dynamically from a handler)."""
69+
70+
type: str # "external_select"
71+
id: str
72+
label: str
73+
placeholder: str
74+
min_query_length: int
75+
optional: bool
76+
# Pre-selected option when the modal opens (must match an option returned
77+
# by the loader). Unlike static :class:`SelectElement`, the initial value
78+
# is the full ``{label, value}`` object since the loader has not run yet.
79+
initial_option: SelectOptionElement
80+
81+
5582
# Union of all modal child types
56-
ModalChild = TextInputElement | SelectElement | RadioSelectElement | TextElement | FieldsElement
83+
ModalChild = TextInputElement | SelectElement | ExternalSelectElement | RadioSelectElement | TextElement | FieldsElement
5784

5885

5986
class ModalElement(TypedDict, total=False):
@@ -69,7 +96,7 @@ class ModalElement(TypedDict, total=False):
6996
children: list[ModalChild]
7097

7198

72-
VALID_MODAL_CHILD_TYPES = {"text_input", "select", "radio_select", "text", "fields"}
99+
VALID_MODAL_CHILD_TYPES = {"text_input", "select", "external_select", "radio_select", "text", "fields"}
73100

74101

75102
def is_modal_element(value: Any) -> bool:
@@ -180,6 +207,38 @@ def Select(
180207
return result
181208

182209

210+
def ExternalSelect(
211+
*,
212+
id: str,
213+
label: str,
214+
placeholder: str | None = None,
215+
min_query_length: int | None = None,
216+
optional: bool | None = None,
217+
initial_option: SelectOptionElement | None = None,
218+
) -> ExternalSelectElement:
219+
"""Build an :class:`ExternalSelectElement` dict.
220+
221+
Slack-only: renders to a Block Kit ``external_select`` element whose
222+
options are populated by an :func:`Chat.on_options_load` handler at
223+
runtime. ``initial_option`` is the full ``{label, value}`` object (the
224+
loader hasn't run yet so just a value string would be ambiguous).
225+
"""
226+
result: ExternalSelectElement = {
227+
"type": "external_select",
228+
"id": id,
229+
"label": label,
230+
}
231+
if placeholder is not None:
232+
result["placeholder"] = placeholder
233+
if min_query_length is not None:
234+
result["min_query_length"] = min_query_length
235+
if optional is not None:
236+
result["optional"] = optional
237+
if initial_option is not None:
238+
result["initial_option"] = initial_option
239+
return result
240+
241+
183242
def SelectOption(
184243
*,
185244
label: str,
@@ -227,5 +286,6 @@ def RadioSelect(
227286
modal = Modal
228287
text_input = TextInput
229288
select = Select
289+
external_select = ExternalSelect
230290
select_option = SelectOption
231291
radio_select = RadioSelect

src/chat_sdk/types.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from chat_sdk.cards import CardElement
2020
from chat_sdk.errors import ChatNotImplementedError
2121
from chat_sdk.logger import Logger, LogLevel
22-
from chat_sdk.modals import SelectOptionElement
22+
from chat_sdk.modals import OptionsLoadGroup, SelectOptionElement
23+
24+
# A handler may return either a flat list of options or a list of labeled
25+
# groups (Slack's ``option_groups`` shape). Mirrors upstream TS
26+
# ``OptionsLoadResult = SelectOptionElement[] | OptionsLoadGroup[]``.
27+
OptionsLoadResult = list[SelectOptionElement] | list[OptionsLoadGroup]
2328

2429

2530
def _parse_iso(s: str) -> datetime:
@@ -1424,7 +1429,7 @@ def process_modal_submit(
14241429
) -> Awaitable[ModalResponse | None]: ...
14251430
def process_options_load(
14261431
self, event: OptionsLoadEvent, options: WebhookOptions | None = None
1427-
) -> Awaitable[list[SelectOptionElement] | None]: ...
1432+
) -> Awaitable[OptionsLoadResult | None]: ...
14281433
def process_modal_close(
14291434
self, event: Any, context_id: str | None = None, options: WebhookOptions | None = None
14301435
) -> None: ...

0 commit comments

Comments
 (0)