Skip to content

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

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

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

@patrick-chinchill patrick-chinchill commented May 8, 2026

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

Summary by CodeRabbit

Release Notes

  • New Features
    • Added ExternalSelect modal component for dynamic option selection with customizable placeholder and minimum query length settings
    • Extended options loading system to support grouped options (OptionGroups) alongside traditional flat option lists
    • Options load handlers can now return either grouped or flat option structures based on use case requirements

Review Change Stack

, #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

📝 Walkthrough

Walkthrough

This PR extends the chat SDK to support external select modal elements with grouped option loading. It introduces new types for option groups and external select elements, updates the options loading handler pipeline to return grouped results, implements Slack adapter serialization for both flat and grouped options with constraints, and adds comprehensive test coverage for all code paths.

Changes

External Select Modal with Grouped Options Loading

Layer / File(s) Summary
Type Contracts and Schemas
src/chat_sdk/types.py, src/chat_sdk/modals.py
OptionsLoadResult union type alias added. New OptionsLoadGroup and ExternalSelectElement TypedDict schemas defined for grouped option responses and external select modal elements.
Modal Builder & Child Support
src/chat_sdk/modals.py
ExternalSelect(...) builder function created to construct external select elements with optional placeholder, min_query_length, and initial_option. ModalChild union extended. VALID_MODAL_CHILD_TYPES updated to recognize "external_select".
Options Loading Handler Pipeline
src/chat_sdk/chat.py, src/chat_sdk/types.py
OptionsLoadHandler type alias updated to return OptionsLoadResult | None. Chat.process_options_load(...) signature and ChatInstance.process_options_load protocol method return type changed to support grouped and flat options.
Slack Adapter Serialization
src/chat_sdk/adapters/slack/adapter.py
_options_load_response(...) reimplemented to detect and serialize either flat options or option_groups (capped at 100 each). _select_option_to_slack(...) static helper converts options to Slack format with optional description field.
Slack Modal Block Rendering
src/chat_sdk/adapters/slack/modals.py
_external_select_to_block(...) added to render external select elements as Slack Block Kit blocks, conditionally including placeholder, min_query_length, and initial_option with falsy-value-aware omission. Modal child dispatcher extended for external_select type.
Public API Exports
src/chat_sdk/__init__.py
ExternalSelect, external_select, ExternalSelectElement, OptionsLoadGroup, and OptionsLoadResult imported and added to __all__ for public consumption.
Tests
tests/test_modals.py, tests/test_slack_modals.py, tests/test_chat_faithful.py, tests/test_options_load.py
TestExternalSelectBuilder validates builder behavior and optional field omission. TestModalExternalSelect covers Slack block rendering with edge cases (min_query_length=0, initial_option handling). TestOptionsLoad and option-load tests verify grouped result handling, mutual exclusivity of groups/options, label truncation to 75 chars, per-group/option caps at 100, empty result as flat {"options": []}, and description preservation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 With whiskers twitching, I've added the way
To group options dynamically, come what may!
External selects now flourish with care,
While grouped results float through the Slack-filled air.
Load options abroad, flat or grouped—both fair! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding ExternalSelect support with initialOption and option_groups functionality, referenced by issue numbers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 10, 2026 03:47
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/chat_sdk/adapters/slack/adapter.py (1)

1149-1163: 💤 Low value

LGTM! Clean option serialization helper.

The static method correctly converts SelectOptionElement to Slack's option object shape, properly omitting the description field when not provided.

Optional refinement for strict guideline compliance:

Line 1161 uses if desc: which treats both None and "" as falsy. For strict compliance with the "avoid truthiness traps" guideline, consider:

desc = opt.get("description")
if desc is not None:
    entry["description"] = {"type": "plain_text", "text": desc}

However, the current implementation is probably intentional — an empty-string description isn't useful and shouldn't be sent to Slack. The behavior matches upstream TypeScript's falsy check.

As per coding guidelines: "Use x if x is not None else default instead of x or default to avoid truthiness traps when porting from TypeScript."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat_sdk/adapters/slack/adapter.py` around lines 1149 - 1163, The current
_select_option_to_slack method uses "if desc:" which treats empty strings as
falsy; update the check to explicitly test for None so an intentionally empty
description (if you want to preserve it) isn't dropped — retrieve desc via
opt.get("description") and change the guard to "desc is not None" before adding
entry["description"] = {"type": "plain_text", "text": desc}; keep all other
serialization logic intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/chat_sdk/adapters/slack/adapter.py`:
- Around line 1149-1163: The current _select_option_to_slack method uses "if
desc:" which treats empty strings as falsy; update the check to explicitly test
for None so an intentionally empty description (if you want to preserve it)
isn't dropped — retrieve desc via opt.get("description") and change the guard to
"desc is not None" before adding entry["description"] = {"type": "plain_text",
"text": desc}; keep all other serialization logic intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 80868de3-d2da-4d77-95f6-638996be8bcf

📥 Commits

Reviewing files that changed from the base of the PR and between 04c0658 and 99a52a4.

📒 Files selected for processing (10)
  • src/chat_sdk/__init__.py
  • src/chat_sdk/adapters/slack/adapter.py
  • src/chat_sdk/adapters/slack/modals.py
  • src/chat_sdk/chat.py
  • src/chat_sdk/modals.py
  • src/chat_sdk/types.py
  • tests/test_chat_faithful.py
  • tests/test_modals.py
  • tests/test_options_load.py
  • tests/test_slack_modals.py

@patrick-chinchill patrick-chinchill marked this pull request as draft May 10, 2026 06:05
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 10, 2026 06:05
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