fix(slack): avoid assistant status on synthetic top-level threads

When reply_in_thread=false, top-level channel events carry their own
message ts as metadata.thread_id for session keying. Calling
assistant.threads.setStatus on that ts activated a Slack assistant
thread ('is thinking...') before the actual response was sent, and the
flat reply then never cleared it.

send_typing now routes through the same _resolve_thread_ts synthetic-
thread guard as message sending, and the gateway threads message_id
through progress/status metadata so the adapter can distinguish real
threads from synthetic top-level session keys.

Reapplied from #18859-sibling PR #17184 by @dorukardahan (both commits:
fix + progress-metadata test) onto current main via 3-way apply — the
original patched gateway/platforms/slack.py, moved to
plugins/platforms/slack/adapter.py in the plugin migration.
This commit is contained in:
Doruk Ardahan 2026-07-23 07:56:10 -07:00 committed by Teknium
parent 62747aa584
commit e40a38aa29
4 changed files with 66 additions and 5 deletions

View file

@ -16279,6 +16279,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
metadata["direct_messages_topic_id"] = tid
if reply_to_message_id is not None:
metadata["telegram_reply_to_message_id"] = str(reply_to_message_id)
if platform == Platform.SLACK and reply_to_message_id is not None:
# Slack's reply_in_thread=false path uses message_id to distinguish
# real existing threads from synthetic top-level session keys.
metadata["message_id"] = str(reply_to_message_id)
return metadata
@staticmethod
@ -20054,7 +20058,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_progress_metadata = (
self._thread_metadata_for_source(source, event_message_id)
if _progress_thread_id == source.thread_id
else {"thread_id": _progress_thread_id}
else self._thread_metadata_for_target(
source.platform,
source.chat_id,
_progress_thread_id,
chat_type=getattr(source, "chat_type", None),
reply_to_message_id=event_message_id,
)
) if _progress_thread_id else None
_progress_metadata = _non_conversational_metadata(_progress_metadata, platform=source.platform)
_progress_reply_to = (
@ -20517,7 +20527,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"reply_to_message_id": event_message_id,
}
else:
_status_thread_metadata = self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id else None
_status_thread_metadata = (
self._thread_metadata_for_source(source, event_message_id)
if _progress_thread_id == source.thread_id
else self._thread_metadata_for_target(
source.platform,
source.chat_id,
_progress_thread_id,
chat_type=getattr(source, "chat_type", None),
reply_to_message_id=event_message_id,
)
) if _progress_thread_id else None
def _status_callback_sync(event_type: str, message: str) -> None:
if not _status_adapter or not _run_still_current():

View file

@ -2536,7 +2536,15 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = None
if metadata:
thread_ts = metadata.get("thread_id") or metadata.get("thread_ts")
# Reuse the same synthetic-thread guard as message sending. When
# reply_in_thread=false, top-level channel events carry their own
# message ts as metadata.thread_id for session keying. Calling
# assistant_threads_setStatus on that ts activates a Slack assistant
# thread before the actual response is sent.
thread_ts = self._resolve_thread_ts(
reply_to=metadata.get("message_id"),
metadata=metadata,
)
if not thread_ts:
return # Can only set status in a thread context

View file

@ -521,8 +521,12 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch
assert result["final_response"] == "done"
assert adapter.sent
assert adapter.sent[0]["metadata"] == {"thread_id": "1234567890.000001"}
assert all(call["metadata"] == {"thread_id": "1234567890.000001"} for call in adapter.typing)
expected_metadata = {
"thread_id": "1234567890.000001",
"message_id": "1234567890.000001",
}
assert adapter.sent[0]["metadata"] == expected_metadata
assert all(call["metadata"] == expected_metadata for call in adapter.typing)
@pytest.mark.asyncio

View file

@ -3398,6 +3398,35 @@ class TestSendTyping:
status="is thinking...",
)
@pytest.mark.asyncio
async def test_skips_status_for_synthetic_top_level_when_reply_in_thread_false(self, adapter):
adapter.config.extra["reply_in_thread"] = False
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing(
"C123",
metadata={"thread_id": "171.000", "message_id": "171.000"},
)
adapter._app.client.assistant_threads_setStatus.assert_not_called()
assert adapter._active_status_threads == {}
@pytest.mark.asyncio
async def test_sets_status_for_real_thread_when_reply_in_thread_false(self, adapter):
adapter.config.extra["reply_in_thread"] = False
adapter._app.client.assistant_threads_setStatus = AsyncMock()
await adapter.send_typing(
"C123",
metadata={"thread_id": "171.000", "message_id": "171.500"},
)
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
channel_id="C123",
thread_ts="171.000",
status="is thinking...",
)
@pytest.mark.asyncio
async def test_stop_typing_clears_tracked_thread(self, adapter):
adapter._app.client.assistant_threads_setStatus = AsyncMock()