Skip to content

feat(externalselect): add initialOption + option_groups (vercel/chat#410, #397)#84

Draft
patrick-chinchill wants to merge 2 commits intomainfrom
claude/port-external-select-option-groups-J7S7H
Draft

feat(externalselect): add initialOption + option_groups (vercel/chat#410, #397)#84
patrick-chinchill wants to merge 2 commits intomainfrom
claude/port-external-select-option-groups-J7S7H

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

Summary

Ports two co-dependent upstream PRs that together complete ExternalSelect:

The handler return type widens to OptionsLoadResult = list[SelectOptionElement] | list[OptionsLoadGroup]. The Slack adapter detects grouped form via "options" in result[0] (matching upstream) and emits {"option_groups": [...]} instead of {"options": [...]}.

What landed

  • Core types (src/chat_sdk/modals.py, src/chat_sdk/types.py):
    • ExternalSelectElement TypedDict + ExternalSelect builder + external_select snake-case alias
    • OptionsLoadGroup TypedDict
    • OptionsLoadResult type alias (widened from list[SelectOptionElement])
    • external_select added to VALID_MODAL_CHILD_TYPES
    • Updated OptionsLoadHandler, Chat.process_options_load, ChatInstance.process_options_load return types
  • Slack consumer (src/chat_sdk/adapters/slack/modals.py, src/chat_sdk/adapters/slack/adapter.py):
    • _external_select_to_block renders external_select element with placeholder, min_query_length, initial_option (full {label, value, description?} object — the loader hasn't run yet so a value string would be ambiguous)
    • _options_load_response now branches on the result shape and emits option_groups when grouped, with the Slack 100/100/75 caps applied; _select_option_to_slack extracted to share between the flat and grouped paths
  • Exports (src/chat_sdk/__init__.py): ExternalSelect, ExternalSelectElement, external_select, OptionsLoadGroup, OptionsLoadResult

Tests added (18 new)

  • tests/test_modals.py (TestExternalSelectBuilder, 4 tests) — ports upstream packages/chat/src/modals.test.ts faithfully ("should create with required fields", "should include optional fields") plus Python-specific initial_option and hazard-security: Fix all critical and high findings from security audit #1/fix: launch must-fix items — security, perf, docs #7 coverage
  • tests/test_modals.py — extended filter_modal_children test to include external_select
  • tests/test_slack_modals.py (TestModalExternalSelect, 6 tests) — ports upstream packages/adapter-slack/src/modals.test.ts faithfully ("converts external select with placeholder and min query length", "converts external select with initialOption") plus omit-when-unset, min_query_length=0, description round-trip, optional
  • tests/test_options_load.py (5 new tests in TestSlackBlockSuggestion) — ports upstream packages/adapter-slack/src/index.test.ts "handles block_suggestion with option_groups response", plus mutual-exclusivity, 75-char label truncation, 100x100 group/option caps, empty-result rendering, description round-trip
  • tests/test_chat_faithful.py — added test_should_support_returning_option_groups (faithful port of upstream chat.test.ts "should support returning option groups")

Hazards considered

Adversarial reviewer notes

  • Empty result detection: is_groups requires len(result) > 0 so [] falls through to the flat-options path (renders as {"options": []}, which is what Slack expects for "no results"). Test included.
  • Group-shape detection reads only "options" in result[0]. A flat option that happens to carry an extra options key would be misclassified; this matches upstream TS behavior and is acceptable since SelectOptionElement doesn't define an options key.
  • initial_option description round-trips through the renderer (covered).

Test plan

  • uv run ruff check src/ tests/ scripts/
  • uv run ruff format --check src/ tests/ scripts/
  • uv run python scripts/audit_test_quality.py (0 hard failures)
  • uv run pytest tests/ --tb=short -q — 3686 passed, 2 skipped, 1 pre-existing failure (tests/test_github_webhook.py::TestGitHubAdapterConstructor::test_throws_when_no_auth)
  • TS_ROOT=/tmp/vercel-chat uv run python scripts/verify_test_fidelity.py — no new fidelity gaps; new TS test "should support returning option groups" matched

Upstream refs: vercel/chat#410, vercel/chat#397

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

, #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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ef0b46d-f74c-428c-baf7-38777a00b1f2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/port-external-select-option-groups-J7S7H

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/chat_sdk/types.py
def process_options_load(
self, event: OptionsLoadEvent, options: WebhookOptions | None = None
) -> Awaitable[list[SelectOptionElement] | None]: ...
) -> Awaitable[OptionsLoadResult | None]: ...
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request adds support for ExternalSelect modal elements and grouped options (option_groups) within the Slack adapter. Key changes include the definition of new types like ExternalSelectElement and OptionsLoadGroup, the addition of an ExternalSelect builder, and logic in the Slack adapter to dynamically handle flat or grouped option results from onOptionsLoad handlers. The update also ensures compliance with Slack's specifications regarding field limits and mutual exclusivity of response keys. Extensive tests have been added to verify the builder logic, Slack conversion, and event processing. I have no feedback to provide.

Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Review: feat(externalselect): add initialOption + option_groups

Compared the Python port at 2c00393 against upstream TS at f55378a (chat@4.27.0):

  • packages/chat/src/modals.ts and modals.test.ts
  • packages/chat/src/types.ts (ExternalSelectElement, OptionsLoadGroup, OptionsLoadResult)
  • packages/adapter-slack/src/modals.ts and modals.test.ts
  • packages/adapter-slack/src/index.ts (optionsLoadResponse + handleBlockSuggestion)

🔴 Critical

None.

🟡 Medium

None — coverage is solid for both upstream-mirrored tests and adversarial Python-specific cases (empty [], 100/100/75 caps, mutual exclusivity, description round-trip, min_query_length=0).

🔵 Nit

  1. if initial_option: truthiness in _external_select_to_block (src/chat_sdk/adapters/slack/modals.py:209). TS uses if (select.initialOption), which only filters null/undefined — an object literal like {} is truthy in TS. In Python, {} is falsy, so a malformed initial_option={} would silently render as no initial_option (parity drift). The ExternalSelect builder uses is not None (modals.py:235), so this only fires if a caller hand-constructs the dict with initial_option={}. Practical risk is low, but if initial_option is not None: would match the TS semantics exactly and be consistent with the min_query_length handling two lines above.
  2. Long ModalChild union line (src/chat_sdk/modals.py:83) is 100+ chars and may already trip ruff line length on stricter configs. Consider wrapping with parens.
  3. Docstring in _external_select_to_block says "this just emits the placeholder element" — slight ambiguity since "placeholder" overloads the Block Kit placeholder field. Could read as "emits only the placeholder." Minor wording polish.

✅ Looks good

  • Hazard #1 (truthiness): min_query_length=0 correctly preserved via is not None in both the builder and the renderer; explicit regression test included.
  • Hazard #2 (boundary case): snake_case internally matches Slack's snake_case Block Kit keys, so no transformation needed; OptionsLoadResult type alias mirrors upstream SelectOptionElement[] | OptionsLoadGroup[].
  • Hazard #7 (omit vs None): All optional fields (placeholder, min_query_length, initial_option, description) are conditionally added — never serialized as null. Tests assert key absence.
  • Hazard #15 (behavior parity): End-to-end block_suggestion test confirms the adapter emits option_groups (not both option_groups AND options). Slack's mutual-exclusivity spec is enforced.
  • Empty result []: Falls through to {"options": []} (correct) rather than {"option_groups": []}. The len(result) > 0 short-circuit is the right guard.
  • Group-shape detection: Adds defensive isinstance(result[0].get("options"), list) beyond upstream's Array.isArray check — slightly stricter, defeats malformed inputs like {"options": "string"}. Good Python-side hardening.
  • Slack caps: 100 groups × 100 options × 75-char label all applied correctly; 75-char truncation tested with "x" * 200 → "x" * 75.
  • Shared helper extraction: _select_option_to_slack mirrors upstream's selectOptionToSlackOption factoring; description round-trips through both flat and grouped paths.
  • TS test fidelity: All 3 upstream it() blocks (should create with required fields, should include optional fields, converts external select with placeholder and min query length, converts external select with initialOption, handles block_suggestion with option_groups response, should support returning option groups) have direct Python ports.
  • Exports: ExternalSelect, ExternalSelectElement, external_select, OptionsLoadGroup, OptionsLoadResult all wired through __init__.py __all__.

Net: clean, faithful port. The single nit on if initial_option: is the only place where Python truthiness diverges subtly from TS — worth tightening to is not None for symmetry with min_query_length, but not blocking.

Posted by an automated reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

…ct 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
Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Re-review (post-fix verification + fresh upstream-parity sweep)

Verification of the pushed fix

Confirmed 99a52a4 (adapters/slack/modals.py:208-220) replaces the truthiness check with if initial_option is not None:, matching the TS if (select.initialOption) semantic where {} is truthy in JS but falsy in Python. Verified the new regression test test_external_select_initial_option_empty_dict_renders actually catches the regression by reverting modals.py to commit 2c00393:

  • Before fix: FAILED ... assert 'initial_option' in {'action_id': 'person', 'type': 'external_select'}
  • After fix: 7 passed across TestModalExternalSelect

The test message is excellent (it names the file, function, and the exact remediation). Full affected suite: 204 passed / 2 skipped.

Fresh parity sweep against chat@4.27.0 (f55378a)

Walked the diff against packages/chat/src/{modals,types}.ts, packages/adapter-slack/src/{modals,index}.ts plus their .test.ts siblings.

  • Type shape — ExternalSelectElement: TS has { id, initialOption, label, minQueryLength, optional, placeholder, type }; Python TypedDict has the same set with snake_case translation. ✅
  • OptionsLoadResult / OptionsLoadGroup: shape and union match upstream verbatim (SelectOptionElement[] | OptionsLoadGroup[]). ✅
  • Wire-format mutual exclusivity (option_groups vs options): TS does not enforce — it picks via isGroups = result.length > 0 && "options" in result[0] && Array.isArray(...) and emits exactly one key. Python mirrors this branch identically (adapter.py:1117-1147), and test_option_groups_and_options_are_mutually_exclusive pins it. ✅
  • Group/option caps and label truncation: 100 groups, 100 options/group, 75-char group label — all match upstream slice calls; covered by test_option_groups_limits_to_100_groups_and_100_options and test_option_groups_label_truncated_to_75_chars. ✅
  • Test fidelity: both upstream it()s in chat/src/modals.test.ts > ExternalSelect (should create with required fields, should include optional fields) ported. Both upstream it()s in adapter-slack/src/modals.test.ts (converts external select with placeholder and min query length, converts external select with initialOption) ported, with identical assertion shapes. The new chat.test.ts > should support returning option groups (vercel/chat#410) is ported in test_chat_faithful.py. The adapter-slack/src/index.test.ts > handles block_suggestion with option_groups response is ported in test_options_load.py with the exact same expected JSON. ✅
  • fromReactModalElement ExternalSelect case (modals.test.ts:268): intentionally not ported — covered by the documented "JSX Card/Modal elements" non-parity row in docs/UPSTREAM_SYNC.md:473. ✅
  • Hazard #1 (truthiness traps): the patch correctly uses is not None for both min_query_length and initial_option. placeholder uses truthiness, but TS does too (if (select.placeholder)), and empty-string is falsy in both languages — match. ✅
  • Backward compat: OptionsLoadHandler's return type widens from list[SelectOptionElement] | None to OptionsLoadResult | None (a strict superset), so existing handlers stay valid. ✅

Findings

🔵 Nit (pre-existing, not a blocker): _select_to_block (modals.py:148-157) inlines the option-conversion logic that _external_select_to_block and SlackAdapter._select_option_to_slack now share. Upstream consolidates everything to selectOptionToSlackOption. Worth a follow-up to dedupe, but out of scope for this PR.

✅ Everything else looks good — no remaining parity gaps, no boundary cases missed.

Re-review verdict: PASS

Posted by an automated re-reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

patrick-chinchill pushed a commit that referenced this pull request May 10, 2026
Final upstream-coverage audit before merging the 7 sync PRs (#84-#90)
identified one undocumented N/A item:

vercel/chat#415 (Teams SDK 2.0.8 + User-Agent) is a JS-only botbuilder
dependency bump. The Python Teams adapter uses raw aiohttp (no
botbuilder), so there is no equivalent dependency to bump. The optional
User-Agent: Vercel.ChatSDK header on the ~9 outbound aiohttp call sites
is a defense-in-depth nice-to-have; deferred as a follow-up rather than
landed in this sync.

Updates:
- CHANGELOG.md: tick all completed items and link them to their PRs
  (#84, #85, #86, #87, #88, #89, #90, plus already-merged PR #74).
  Document #415 inline as N/A.
- docs/UPSTREAM_SYNC.md non-parity table: add row for Teams User-Agent
  header divergence so future syncers don't try to "port" the JS bump.

Item #6 (concurrency.maxConcurrent) is already implementation-covered
in the Python port (existing divergence row at L492). The 4 new TS
concurrency tests in chat.test.ts have Python-specific equivalents at
test_chat_faithful.py L2969-3055 that don't name-match — leaving as
deferred fidelity-baseline polish since the behavior is verified.

Verdict from the coverage audit: all 18 substantive ports across PRs
#84-#90 are upstream-verified. No commits in chat@4.26.0..f55378a were
missed. Ready to start merging.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants