fix(slack): read real SDK responses instead of gating on isinstance dict - #74658
fix(slack): read real SDK responses instead of gating on isinstance dict#74658nikitaBarkov wants to merge 2 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating this to the response-shape regression. The premise holds on current main: plugins/platforms/slack/adapter.py:3803-3807 rejects a non-dict users.info response and caches the raw user ID, while the pinned Slack SDK 3.43.0 defines AsyncSlackResponse as a standalone container with response data in .data.
Problems
tests/gateway/test_slack_sdk_response.py:190-250covers SDK-shaped identity, ephemeral, and upload responses, but does not cover the other changed response reads:create_handoff_thread(plugins/platforms/slack/adapter.py:2262) or standalonechat_postMessageand caption fallback (plugins/platforms/slack/adapter.py:8718-8751). Those paths can regress back to losingtswithout this suite detecting it.
Suggested changes
- Add SDK-shaped-response tests for the handoff seed timestamp and standalone text/caption-fallback delivery paths.
Automated hermes-sweeper review.
| @@ -8701,14 +8718,14 @@ def _format_mrkdwn(text: str) -> str: | |||
| if thread_id: | |||
There was a problem hiding this comment.
Please add SDK-shaped-response coverage for this standalone chat_postMessage branch and the caption fallback below. The new suite currently reaches _standalone_upload_file, but not either of these changed ts reads.
|
Confirming this on a production deployment that upgraded It is a regression inside that tag rangeGateway logs going back seven weeks show sender names resolving on every single day up to the upgrade, then breaking within the hour it was deployed (counting how many inbound Slack messages logged a human name vs. a bare user ID):
Worth flagging that #70196 is titled "resolve channel IDs to human-readable names" — the gate it introduced stops that feature from ever taking effect. For contrast, result = await client.users_info(user=user_id)
user = result.get("user", {}) # SlackResponse supports .get(), so this was fineWhy this one site is worse than the other eightThe negative caching itself predates the regression — cached_name = self._user_name_cache.get(cache_key)
if cached_name is not None:
return cached_namemeans Measured on the affected deployment: Observable impactWith instead of In a session with no history there is nothing left to tell the model that the mention refers to itself, so it reads the message as an unrelated channel broadcast and emits Nothing surfaces in the logs: the only record is Minimal reproductionfrom slack_sdk.web.async_slack_response import AsyncSlackResponse
issubclass(AsyncSlackResponse, dict) # False
AsyncSlackResponse.__mro__ # (AsyncSlackResponse, object)payload = {"ok": True, "user": {"real_name": "Alice", "profile": {"real_name": "Alice"}}}
r = AsyncSlackResponse(client=None, http_verb="GET", api_url="x", req_args={},
data=payload, headers={}, status_code=200)
isinstance(r, dict) # False -> failure branch, caches the bare ID
isinstance(getattr(r, "data", r), dict) # True -> resolves "Alice"Still reproducible on On the approach
Happy to test a build of this branch against the workload that surfaced it if that would help move the review along. |
|
Ran into this myself and my Hermes agent diagnosed this same fix. Thanks for this @nikitaBarkov |
Slack Web API calls return `SlackResponse`/`AsyncSlackResponse`, which are
mapping-like but not `dict` subclasses, so every `isinstance(resp, dict)`
gate took its "unexpected shape" branch at runtime: user and channel names
collapsed to raw IDs, every user resolved as a non-bot (defeating the
allow_bots loop guard), ephemeral replies were reported as failures, and
uploads/caption fallbacks lost their message_id.
Normalize responses through a single `_slack_response_payload()` helper
(dict passes through, SDK response yields `.data`, anything else yields
`{}` so callers keep their fallbacks) and use it at every call site.
Existing Slack tests injected plain dicts, which is why the defect was
invisible; the new tests run each behavioral case against a real
`AsyncSlackResponse` as well.
…se reads Review on NousResearch#74658 flagged that the response-shape suite exercised identity, ephemeral and upload paths but left two changed call sites untested: - create_handoff_thread's seed-message ts (adapter.py:2262), which anchors every subsequent handoff send onto the thread; - the standalone media branch's chat_postMessage reads (adapter.py:8721 text post, :8749 caption fallback), where an SDK-shaped reply used to drop the ts and report a caption-only delivery as 'nothing deliverable'. Both new cases run against the hand-rolled stand-in and the real AsyncSlackResponse. Verified they fail against the pre-fix adapter. Co-authored-by: Junie <junie@jetbrains.com>
c3ba272 to
8c1d073
Compare
|
Pushed @teknium1 (sweeper) — missing coverage on the other changed read paths. Correct, the suite stopped at identity/ephemeral/upload. Added:
Both new classes run against the hand-rolled stand-in and the real @chenwei791129 — thanks for the independent production confirmation, that's the most useful thing in this thread. On your suggestion to keep the data = getattr(response, "data", None)
return data if isinstance(data, dict) else {}so a non-JSON body ( Please do run the branch against your load; a second real-workspace confirmation would be worth more than anything else I can add here. @ksylvan — thanks for the confirmation. Suite: |
Re-review requested — corrected head
|
|
Confirming this is still live in v0.20.0 (build 2026.8.3): |
…se reads Review on #74658 flagged that the response-shape suite exercised identity, ephemeral and upload paths but left two changed call sites untested: - create_handoff_thread's seed-message ts (adapter.py:2262), which anchors every subsequent handoff send onto the thread; - the standalone media branch's chat_postMessage reads (adapter.py:8721 text post, :8749 caption fallback), where an SDK-shaped reply used to drop the ts and report a caption-only delivery as 'nothing deliverable'. Both new cases run against the hand-rolled stand-in and the real AsyncSlackResponse. Verified they fail against the pre-fix adapter. Co-authored-by: Junie <junie@jetbrains.com>
|
Merged via PR #85463 with your commits cherry-picked onto current main — authorship preserved in git log. Both commits landed, including the follow-up test coverage for the handoff-thread ts and standalone media send paths. Thanks for the whole-class fix and the production confirmation trail. |
What does this PR do?
Slack user names, channel names and bot detection are silently broken on
main: the agent receives[U0BCE4NRVKN | Slack user <@U0BCE4NRVKN>]instead of[Nikita | Slack user <@U0BCE4NRVKN>], every user resolves as a non-bot, and some send paths report success as failure.Root cause.
slack_sdkWeb API calls returnSlackResponse/AsyncSlackResponse. Those objects are mapping-like (they expose.get()and.data) but they are notdictsubclasses. Commit3f08201ba("Fix Slack peer bot status routing loops", #51627) addedisinstance(result, dict)guards around those responses, so at runtime the guard is always False and every guarded call site takes its "unexpected shape" degradation branch:_resolve_user_name→ the name becomes the raw user id, and that wrong value is cached for the lifetime of the gateway process. This is the user-visible symptom._resolve_user_is_bot→ every user resolves as a non-bot, which defeats theallow_botspeer-bot loop guard that3f08201bawas written to add._resolve_channel_name→ the channel name degrades toC0…._post_ephemeral_fallback→ a successful ephemeral reply is reported as anunexpected_responsefailure._standalone_upload_fileand the standalonechat.postMessagepath →message_idis lost (breaking threading of follow-up sends) and the caption fallback is never marked delivered.Fix. Normalize every Slack response through one helper,
_slack_response_payload(): a plaindictpasses through, an SDK response yields.data, and anything else (including a binary.data, whichfiles_*can return) yields{}so callers keep their existing fallbacks. This fixes the whole bug class rather than the one reported site — all eight guarded call sites are converted.Why the test suite did not catch it. The existing Slack tests inject plain
dicts into the mocked client, so the guard is True in tests and False in production. The new suite parametrizes every behavioral case to run against a realAsyncSlackResponseas well, which is what makes the defect (and any future recurrence) visible.The premise was verified against the runtime, not assumed:
SlackResponse/AsyncSlackResponseMRO ends atobjectin bothslack_sdk3.40.1 and 3.43.0 (the version pinned inpyproject.toml); the return type is fixed in the SDK signatures (users_info(...) -> AsyncSlackResponse), and Bolt hands out that sameAsyncWebClient. No bot setting, OAuth scope, or Slack-side API change can flip this.users.infowith the same bot token returned the correctdisplay_name, ruling out scopes/transport.AsyncSlackResponse:_resolve_user_namereturnedU_HUMANinstead ofNikita; after the fix it returns the name.Related Issue
No open issue — the symptom ("Slack display names became user IDs") does not appear to be reported. The only PR with the same diagnosis, #72062, was closed by its own author without maintainer review; it also bundled two unrelated changes (
_apply_yaml_configandsuccess_reaction/other_agent_patterns). This PR is the isolated fix, extended to the remaining call sites of the same bug class and to a test that exercises the real SDK response shape.Fixes the regression introduced by #51627 (
3f08201ba).Type of Change
Changes Made
plugins/platforms/slack/adapter.py: new module-level_slack_response_payload()normalizer; all Slack Web API call sites read through it —_resolve_user_name,_resolve_user_is_bot,_resolve_channel_name,_post_ephemeral_fallback, the thread seed-posttsread,_standalone_upload_file, and the standalonechat.postMessagepath including the caption fallback. Everyisinstance(resp, dict)gate on an SDK response is removed.tests/gateway/test_slack_sdk_response.py(new, 23 tests): the normalizer's contract (dict passthrough,.data, binary.data→{}, unknown shape →{}), the identity/channel resolution paths, and the send paths — each parametrized over a hand-rolled stand-in and a realAsyncSlackResponse(skipped automatically when theslackextra is not installed). Includes an explicit assertion of the bug's premise (the runtime object fails anisinstancedict gate) and keeps coverage for the intended degradation on a genuinely unreadable response.How to Test
mainand send a message: the text handed to the model is prefixed with[U… | Slack user <@U…>]— the display name has been replaced by the id.[Alice | Slack user <@U…>].scripts/run_tests.sh tests/gateway/test_slack_sdk_response.py tests/gateway/test_slack.py tests/gateway/test_slack_mention.py tests/tools/test_send_message_slack.py -q→ 212 passed, 0 failed (branch cut from currentmain).Checklist
Code
fix(slack): …)scripts/run_tests.shand all tests passDocumentation & Housekeeping
docs/, docstrings) — N/A (restores documented behavior; the new helper is documented in its docstring)cli-config.yaml.exampleif I added/changed config keys — N/A (no config keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
Before (prefix the model receives, from a live gateway's request dump):
After:
Behavioral side effect worth calling out for review: with the guard removed,
_resolve_user_is_botactually recognizes bots again, so theallow_botspolicy starts enforcing as #51627 intended.