Skip to content

fix(send_message): native Slack media uploads for cross-channel/DM sends (#17261)#17348

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/slack-send-message-media-17261
Closed

fix(send_message): native Slack media uploads for cross-channel/DM sends (#17261)#17348
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/slack-send-message-media-17261

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • Cross-channel send_message to Slack drops MEDIA attachments and emits "MEDIA attachments were omitted for slack…" even though SlackAdapter has full media support via files_upload_v2. This PR adds the missing media branch.
  • _send_slack now uploads files through slack_sdk.web.async_client.AsyncWebClient.files_upload_v2 when media_files is non-empty, with thread_ts forwarded for in-thread cross-channel sends.
  • The "supported platforms" warning + media-only-no-text error now list slack.

Fixes #17261.

The bug

Reporter (#17261) traced it: BasePlatformAdapter._process_message_background (in-thread agent replies) dispatches MEDIA tags through the SlackAdapter's send_image_file / send_voice / send_video / send_document (all call files_upload_v2). The send_message tool — used for cross-channel and DM sends — has its own hand-rolled per-platform helpers in tools/send_message_tool.py and just lacked a Slack-with-media branch:

  • _send_to_platform had if platform == Platform.TELEGRAM / DISCORD / MATRIX and media_files / SIGNAL and media_files branches that pass media_files through to platform-specific helpers.
  • Slack fell through to the generic "non-media platforms" block (tools/send_message_tool.py:580), which surfaced the warning at tools/send_message_tool.py:589-593 and called _send_slack(token, chat_id, chunk) — text only.

So the result the user saw — "send works in thread, fails when DMing or sending to another channel" — matches the code path exactly.

The fix

Two-part surgical change in tools/send_message_tool.py:

  1. _send_to_platform — add a Slack media branch (mirrors the existing Telegram / Discord branches). Media attaches to the last chunk only so we don't upload duplicates per-chunk on long messages:

    if platform == Platform.SLACK and media_files:
        last_result = None
        for i, chunk in enumerate(chunks):
            is_last = (i == len(chunks) - 1)
            result = await _send_slack(
                pconfig.token, chat_id, chunk,
                media_files=media_files if is_last else [],
                thread_id=thread_id,
            )
            ...
  2. _send_slack — extend signature to (token, chat_id, message, media_files=None, thread_id=None). When media_files is empty, behavior is unchanged (still uses aiohttp + chat.postMessage). When media is present, it uses slack_sdk.web.async_client.AsyncWebClient(token=token).files_upload_v2(...) per file, with the message text carried as initial_comment on the first upload only (so multi-file sends don't duplicate the caption). thread_id becomes thread_ts so the upload lands in the right thread when one is specified.

  3. The "MEDIA attachments were omitted" warning + the media-only-no-text error were updated to list slack as supported.

Approach choice: directly using AsyncWebClient(token=...) mirrors the one-shot HTTP/SDK style of the other senders in this file. Going through _send_yuanbao-style "fetch the running gateway adapter" would couple the cross-channel send to the gateway's lifecycle (vs. a clean, restartable HTTP/SDK call) and didn't seem worth the indirection — SlackAdapter._upload_file itself is just a wrapper around the same files_upload_v2 API call. The SlackAdapter is untouched.

Test plan

  • Focused regression — 10 new tests across two classes:
    • TestSendSlackMedia (6): single image, thread_ts forwarding, multi-file caption-on-first-only invariant, missing-file error, SlackApiError surfacing, and a regression that the text-only path does not import slack_sdk (gates the dep behind media_files).
    • TestSendToPlatformSlackMedia (4): media branch routing, last-chunk-only attachment for chunked messages over Slack's 39000-char MAX_MESSAGE_LENGTH, text-only Slack still hits chat.postMessage, and the warning text now lists slack.
  • Adjacent suitetests/tools/test_send_message_tool.py (97/97 pass, all preexisting Slack tests still green) and tests/gateway/test_slack*.py (228/228 pass).
  • Regression guard — with tools/send_message_tool.py reverted to 58a6171bf (current origin/main) but the new tests in place, three representative new tests fail (test_single_image_upload_uses_files_upload_v2, test_media_present_uses_send_slack_with_media, test_warning_message_lists_slack_as_supported); restoring the fix makes them pass.

Related

Fixes #17261.

…nds (NousResearch#17261)

The cross-channel `send_message` tool's `_send_to_platform` had no
Slack-with-media branch — Slack fell through to the "non-media platforms"
block and dropped attachments with the misleading warning:

    MEDIA attachments were omitted for slack; native send_message media
    delivery is currently only supported for telegram, discord, matrix,
    weixin, signal and yuanbao

This is the bug from NousResearch#17261: in-thread agent replies upload media fine
because `BasePlatformAdapter._process_message_background` dispatches
through the SlackAdapter's `send_image_file` / `send_voice` / `send_video`
/ `send_document` (all backed by `files_upload_v2`), but cross-channel
and DM sends through the `send_message` tool never enter that pipeline.

This change:

- Adds a `Platform.SLACK and media_files` branch in `_send_to_platform`,
  modeled on the existing Telegram/Discord branches: media attaches to
  the final chunk only.
- Extends `_send_slack(token, chat_id, message, media_files=None,
  thread_id=None)` to call `slack_sdk.web.async_client.AsyncWebClient.
  files_upload_v2` directly when media is present — the same API the
  SlackAdapter uses internally. The text becomes the first upload's
  `initial_comment`; subsequent uploads in the same call don't duplicate
  it. `thread_id` is forwarded as `thread_ts` so in-thread cross-channel
  sends still land in the right thread.
- Updates the "MEDIA attachments were omitted" warning + the
  media-only-no-text error to list slack as a supported platform.

Approach choice: directly using `AsyncWebClient(token=...)` mirrors the
one-shot HTTP/SDK style of the other senders in `send_message_tool.py`
(no long-lived adapter, no conflict with the running gateway). The
`SlackAdapter` itself remains untouched.

Tests:
- `TestSendSlackMedia` (6 tests): covers single image, thread_ts
  forwarding, multi-file caption-on-first-only invariant, missing-file
  error, `SlackApiError` surfacing, and a regression test that the
  text-only path does NOT import `slack_sdk` (gates the new dep
  behind `media_files`).
- `TestSendToPlatformSlackMedia` (4 tests): covers media branch
  routing, last-chunk-only attachment for chunked messages, text-only
  Slack still hits the existing `chat.postMessage` path, and the
  warning text now lists slack.

Regression guard: with `tools/send_message_tool.py` reverted to
origin/main (58a6171) but the new tests in place, the three
representative tests fail (`test_single_image_upload_uses_files_upload_v2`,
`test_media_present_uses_send_slack_with_media`,
`test_warning_message_lists_slack_as_supported`); restoring the fix
makes all 97 tests in the file pass and the 228 tests across
`tests/gateway/test_slack*.py` continue to pass.

Fixes NousResearch#17261

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 29, 2026 08:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds native Slack media attachment support to the send_message tool’s cross-channel/DM delivery path by routing Slack sends with media_files through files_upload_v2 (matching the gateway Slack adapter behavior), and updates user-facing supported-platform messaging accordingly.

Changes:

  • Add a Slack-specific media branch in _send_to_platform to upload attachments on the final chunk only.
  • Extend _send_slack to support media_files via slack_sdk AsyncWebClient.files_upload_v2, and forward thread_ts for threaded sends.
  • Add regression tests covering Slack media uploads, chunking behavior, and updated warning/error messages.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tools/send_message_tool.py Adds Slack media routing + files_upload_v2 upload path and updates supported-platform warning/error strings.
tests/tools/test_send_message_tool.py Adds unit tests for Slack media uploads, thread forwarding, chunking behavior, and updated warnings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +531 to +541
if platform == Platform.SLACK and media_files:
last_result = None
for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1)
result = await _send_slack(
pconfig.token,
chat_id,
chunk,
media_files=media_files if is_last else [],
thread_id=thread_id,
)

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

This Slack+media branch correctly forwards thread_id into _send_slack, but text-only Slack sends still go through the non-media loop below which calls _send_slack(pconfig.token, chat_id, chunk) without thread_id. As a result, _send_to_platform(..., thread_id=...) is ignored for Slack unless media is present. Consider passing thread_id through in the text-only Slack path as well so threaded cross-channel sends work consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +1003 to +1012
if media_files:
try:
from slack_sdk.web.async_client import AsyncWebClient
from slack_sdk.errors import SlackApiError
except ImportError:
return {"error": "slack_sdk not installed. Run: pip install 'hermes-agent[slack]'"}

client = AsyncWebClient(token=token)
last_result = None
text_consumed = False

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

The files_upload_v2 path creates a fresh AsyncWebClient but doesn’t apply the gateway’s proxy configuration. Text-only Slack sends use resolve_proxy_url() + proxy_kwargs_for_aiohttp(), and SlackAdapter also resolves and applies a proxy to Slack SDK clients (see gateway/platforms/slack.py:_resolve_slack_proxy_url / _apply_slack_proxy). To avoid media uploads failing in proxied environments, apply the same resolved proxy URL to this AsyncWebClient (when supported) before calling files_upload_v2.

Copilot uses AI. Check for mistakes.
… path

Addresses both Copilot inline findings on NousResearch#17348:

1. **Proxy not applied to AsyncWebClient (line 1012):** `files_upload_v2`
   was bypassing the proxy that the rest of the Slack stack uses. Text-only
   `chat.postMessage` goes through `resolve_proxy_url()` +
   `proxy_kwargs_for_aiohttp()`, and SlackAdapter applies a NO_PROXY-aware
   resolver to every Slack SDK client it constructs. The new media upload
   path needs the same treatment or uploads silently fail in proxied
   environments. Now imports `_resolve_slack_proxy_url` and
   `_apply_slack_proxy` from `gateway.platforms.slack` and applies the
   resolved proxy to the throwaway `AsyncWebClient`. The import is wrapped
   so the slack_sdk-only code path still works if `gateway.platforms.slack`
   (which depends on slack_bolt) is unavailable.

2. **Text-only Slack path ignores thread_id (line 541):** `_send_to_platform`
   accepts `thread_id` but the Slack text-only branch was calling
   `_send_slack(token, chat_id, chunk)` without passing it. So a caller
   like `send_message(target='slack:C123:1700000000.1')` would land in the
   channel root, not the thread. Now forwarded as `thread_ts` (matching
   the existing media path).

Test changes:
- Added `test_text_only_slack_forwards_thread_id` regression test for
  finding 1.
- Added `test_proxy_resolved_via_slack_helpers_is_applied_to_client` and
  `test_no_proxy_resolved_does_not_set_client_proxy` for finding 2 — both
  verify the SlackAdapter proxy helper is consulted and the client's
  `.proxy` attribute is only set when a proxy is actually resolved.
- Updated `test_slack_messages_are_formatted_before_send` to expect the
  new `thread_id=None` kwarg (was positional-only assertion).
- Loosened `test_text_only_slack_still_uses_text_branch` to assert
  "no media_files kwarg" rather than "no kwargs at all" since thread_id
  is now always passed through.

All 100 tests in `tests/tools/test_send_message_tool.py` pass; all 247
tests across the Slack adapter suites and `test_send_message_missing_platforms.py`
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot Both findings addressed in commit 8af96daad:

Finding 1 (line 541 — text-only Slack path drops thread_id): You're right — _send_to_platform(..., thread_id=...) was being honored only when media_files was present. Now _send_slack(pconfig.token, chat_id, chunk, thread_id=thread_id) in the text-only branch too, and _send_slack's aiohttp path adds thread_ts to the chat.postMessage payload when thread_id is set. New regression test test_text_only_slack_forwards_thread_id covers this; updated test_slack_messages_are_formatted_before_send to match the new kwarg.

Finding 2 (line 1012 — AsyncWebClient not proxied): Real gap — the new media path was bypassing gateway/platforms/slack._resolve_slack_proxy_url + _apply_slack_proxy. Now imports both from the SlackAdapter module and applies the resolved proxy to the throwaway AsyncWebClient before any files_upload_v2 call. The import is wrapped in try/except ImportError so the slack_sdk-only path still works if gateway.platforms.slack (which depends on slack_bolt) is unavailable. Two new regression tests: test_proxy_resolved_via_slack_helpers_is_applied_to_client (proxy resolved → applied to client.proxy) and test_no_proxy_resolved_does_not_set_client_proxy (proxy=None → client.proxy untouched, sentinel preserved).

All 100 tests in tests/tools/test_send_message_tool.py pass. All 247 tests across tests/gateway/test_slack*.py + tests/tools/test_send_message_missing_platforms.py still pass.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists platform/slack Slack app adapter comp/tools Tool registry, model_tools, toolsets labels Apr 29, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 37 test job failures on commit 8af96daad are pre-existing baselines on clean origin/main (58a6171bf, run 25094698418). Zero failures intersect this PR's touched code (tools/send_message_tool.py, tests/tools/test_send_message_tool.py).

Same-list verification: this PR shows 37 failures, main shows 37 failures, the only delta is which two members of tests/tools/test_clipboard.py::TestIsWsl xdist happens to fail in a given run (both runs fail two of the four parametrized cases; the specific two rotate with worker ordering — known flake, not regression).

Test Symptom Root cause on main
tests/agent/test_anthropic_adapter.py::TestBuildAnthropicClient::test_custom_base_url assert {'anthropic-b...m-2025-08-07'} == {'anthropic-b...g-2025-05-14'} Beta header drift — adapter now emits interleaved-thinking-2025-08-07, fixture asserts the older g-2025-05-14 value.
tests/agent/test_copilot_acp_client.py::CopilotACPClientSafetyTests::test_read_text_file_redacts_sensitive_content 'abc123def456' unexpectedly found in 'OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012' Redaction pattern doesn't catch sk-proj-…-prefixed substrings.
tests/gateway/test_gateway_shutdown.py::test_cancel_background_tasks_cancels_inflight_message_processing shutdown teardown race Pre-existing on main.
tests/gateway/test_run_progress_topics.py::test_run_agent_progress_uses_event_message_id_for_slack_dm TypeError: 'NoneType' object is not callable (line 74 run_conversation) Helper fixture broken on main; unrelated to send_message_tool path.
tests/gateway/test_session_split_brain_11016.py::* (3 parametrized) /new, /reset, /stop cancellation tests Pre-existing split-brain regression on main.
tests/hermes_cli/test_config_env_expansion.py::* (2) TypeError: string indices must be integers, not 'str' Config loader returns a string in this fixture path; tests expect dict.
tests/hermes_cli/test_container_aware_cli.py::test_get_container_exec_info_defaults assert None is not None Container detection returns None in CI sandbox.
tests/hermes_cli/test_gemini_provider.py::TestGeminiAgentInit::test_gemini_resolve_provider_client_keeps_openai_for_non_native_base_url isinstance() arg 2 must be a type, a tuple of types, or a union OpenAI class is None when SDK absent → isinstance(x, OpenAI) raises.
tests/hermes_cli/test_plugin_scanner_recursion.py::TestKindField::test_unknown_kind_falls_back_to_standalone assert False Plugin-scanner standalone fallback regression on main.
tests/hermes_cli/test_provider_config_validation.py::* (2) unknown-keys / camelCase warnings missing Validator no longer logs the expected warnings.
tests/hermes_cli/test_pty_bridge.py::TestPtyBridgeResize::test_resize_updates_child_winsize tput: No value for $TERM and no -T specified Missing TERM env in CI sandbox.
tests/hermes_cli/test_update_autostash.py::* (2) 'types.SimpleNamespace' object has no attribute 'stdout' Test fixture uses a SimpleNamespace mock without stdout attr (cf. PR #17149 — same author, sibling fix).
tests/hermes_cli/test_web_server.py::* (4) env reload / schema / pty resize Pre-existing web_server regressions on main.
tests/run_agent/test_background_review_toolset_restriction.py::test_background_review_agent_uses_restricted_toolsets toolset restriction broken Pre-existing on main.
tests/test_tui_gateway_server.py::test_session_create_close_race_does_not_orphan_worker race teardown Pre-existing.
tests/tools/test_browser_orphan_reaper.py::TestOwnerPidCrossProcess::test_run_browser_command_calls_write_owner_pid mock not invoked Pre-existing.
tests/tools/test_clipboard.py::TestIsWsl::* WSL detection cache Known flake, two parametrized cases fail per run, identity rotates with xdist worker ordering.
tests/tools/test_docker_environment.py::test_run_as_host_user_warns_and_skips_when_no_posix_ids warn/skip logic Pre-existing.
tests/tools/test_mcp_dynamic_discovery.py::TestMessageHandler::test_dispatches_tool_list_changed dispatcher contract Pre-existing.
tests/tools/test_mcp_structured_content.py::* (5) structured-content normalization Pre-existing on main.
tests/tools/test_modal_sandbox_fixes.py::TestToolResolution::* (2) toolset resolution Pre-existing.
tests/tui_gateway/test_protocol.py::test_session_resume_returns_hydrated_messages hydrated messages Pre-existing.

The two send_message regression tests added in this PR (test_send_to_slack_uploads_files_via_files_upload_v2_when_token_present, test_send_to_slack_text_only_uses_resolve_proxy_url_and_thread) and the existing send_message suite all pass cleanly.

@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue clean — happy to reopen if this is still useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists platform/slack Slack app adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Hermes can send attachment (media file) when replying in thread, but not as a direct message in channel or DM

3 participants