feat: only post Slack 'Working on it!' on first thread mention (#1250)

* feat: only post Slack 'Working on it!' on first thread mention

* feat: randomize Slack trace reply phrase

Pick from a small list of friendly phrases instead of always saying
'Working on it!' so the bot feels less robotic. Explicit messages (e.g.
'Taking a look...' from PR review path) are unaffected.

* adjust phrases

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
open-swe[bot] 2026-05-07 12:37:46 -07:00 • committed by GitHub
parent bb836b58bc
commit 5a01aff1c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 128 additions and 9 deletions

View file

@ -150,7 +150,7 @@ Do not use this tool for GitHub API calls. Use `GH_TOKEN=dummy gh` in the sandbo
Posts a comment to a Linear ticket given a `ticket_id`. Call this after opening/updating the pull request to notify stakeholders and include the PR link. You can tag Linear users with `@username` (their Linear display name). Posts a comment to a Linear ticket given a `ticket_id`. Call this after opening/updating the pull request to notify stakeholders and include the PR link. You can tag Linear users with `@username` (their Linear display name).
#### `slack_thread_reply` #### `slack_thread_reply`
Posts a message to the active Slack thread. Use this for clarifying questions, status updates, and final summaries when the task was triggered from Slack. Posts a message to the active Slack thread. Use this for clarifying questions, mid-run progress updates, and final summaries when the task was triggered from Slack. You can call it multiple times during a run — if you're about to do something long-running (cloning a large repo, big refactors, running heavy test suites), post a short status update first so the user knows what's happening. Always end the run with a final reply that summarizes what you did or answers the question. Do not post a status reply before quick, single-tool answers — only when the user would otherwise be left waiting.
Format messages using Slack's mrkdwn format, NOT standard Markdown. Format messages using Slack's mrkdwn format, NOT standard Markdown.
Key differences: *bold*, _italic_, ~strikethrough~, <url|link text>, Key differences: *bold*, _italic_, ~strikethrough~, <url|link text>,
bullet lists with "• ", ```code blocks```, > blockquotes. bullet lists with "• ", ```code blocks```, > blockquotes.

View file

@ -9,6 +9,12 @@ from ..utils.slack import convert_mentions_to_slack_format, post_slack_thread_re
def slack_thread_reply(message: str) -> dict[str, Any]: def slack_thread_reply(message: str) -> dict[str, Any]:
"""Post a message to the current Slack thread. """Post a message to the current Slack thread.
Use this for clarifying questions, mid-run progress updates, and the final
summary. You can call this multiple times during a run — if you're about to
do long-running work (cloning, large refactors, big test runs) consider
posting a brief status update first so the user knows what's happening.
Always end the run with a final reply summarizing what you did.
Format messages using Slack's mrkdwn format, NOT standard Markdown. Format messages using Slack's mrkdwn format, NOT standard Markdown.
Key differences: *bold*, _italic_, ~strikethrough~, <url|link text>, Key differences: *bold*, _italic_, ~strikethrough~, <url|link text>,
bullet lists with "• ", ```code blocks```, > blockquotes. bullet lists with "• ", ```code blocks```, > blockquotes.

View file

@ -7,6 +7,7 @@ import hashlib
import hmac import hmac
import logging import logging
import os import os
import random
import re import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -596,10 +597,25 @@ async def resolve_slack_links_in_context(
return resolved_links_section, image_urls return resolved_links_section, image_urls
TRACE_REPLY_PHRASES: tuple[str, ...] = (
"Working on it!",
"On it!",
"Diving in!",
"Powering up!",
"Heads down.",
"Cracking knuckles...",
"Spinning up...",
"Looking...",
"Time to cook. 🧑‍🍳",
)
async def post_slack_trace_reply( async def post_slack_trace_reply(
channel_id: str, thread_ts: str, thread_id: str, message: str = "Working on it!" channel_id: str, thread_ts: str, thread_id: str, message: str | None = None
) -> None: ) -> None:
"""Post a trace URL reply in a Slack thread.""" """Post a trace URL reply in a Slack thread."""
if message is None:
message = random.choice(TRACE_REPLY_PHRASES)
trace_url = get_langsmith_trace_url(thread_id) trace_url = get_langsmith_trace_url(thread_id)
if trace_url: if trace_url:
await post_slack_thread_reply(channel_id, thread_ts, f"{message} <{trace_url}|View trace>") await post_slack_thread_reply(channel_id, thread_ts, f"{message} <{trace_url}|View trace>")

View file

@ -863,6 +863,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
} }
langgraph_client = get_client(url=LANGGRAPH_URL) langgraph_client = get_client(url=LANGGRAPH_URL)
is_first_mention = not await _thread_exists(thread_id)
await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client) await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
thread_active = await is_thread_active(thread_id) thread_active = await is_thread_active(thread_id)
@ -895,7 +896,13 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
_run_id_for_logging(run), _run_id_for_logging(run),
thread_id, thread_id,
) )
await post_slack_trace_reply(channel_id, thread_ts, thread_id) if is_first_mention:
await post_slack_trace_reply(channel_id, thread_ts, thread_id)
else:
logger.info(
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
thread_id,
)
async def process_slack_pr_review_request( async def process_slack_pr_review_request(

View file

@ -3,12 +3,15 @@ import asyncio
import pytest import pytest
from agent import webapp from agent import webapp
from agent.utils import slack as slack_utils
from agent.utils.slack import ( from agent.utils.slack import (
TRACE_REPLY_PHRASES,
convert_mentions_to_slack_format, convert_mentions_to_slack_format,
format_slack_messages_for_prompt, format_slack_messages_for_prompt,
looks_like_slack_pr_review_command, looks_like_slack_pr_review_command,
parse_github_pr_url, parse_github_pr_url,
parse_slack_review_command, parse_slack_review_command,
post_slack_trace_reply,
replace_bot_mention_with_username, replace_bot_mention_with_username,
select_slack_context_messages, select_slack_context_messages,
strip_bot_mention, strip_bot_mention,
@ -218,6 +221,39 @@ def test_format_slack_messages_for_prompt_replaces_bot_id_mention_in_text() -> N
assert formatted == "@alice(U123): @open-swe status update?" assert formatted == "@alice(U123): @open-swe status update?"
def test_post_slack_trace_reply_picks_random_phrase_when_no_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
posted: list[str] = []
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> None:
posted.append(text)
monkeypatch.setattr(slack_utils, "post_slack_thread_reply", fake_post_slack_thread_reply)
monkeypatch.setattr(slack_utils, "get_langsmith_trace_url", lambda thread_id: None)
asyncio.run(post_slack_trace_reply("C123", "1.0", "thread-id"))
assert len(posted) == 1
assert posted[0] in TRACE_REPLY_PHRASES
def test_post_slack_trace_reply_uses_explicit_message_when_provided(
monkeypatch: pytest.MonkeyPatch,
) -> None:
posted: list[str] = []
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> None:
posted.append(text)
monkeypatch.setattr(slack_utils, "post_slack_thread_reply", fake_post_slack_thread_reply)
monkeypatch.setattr(slack_utils, "get_langsmith_trace_url", lambda thread_id: None)
asyncio.run(post_slack_trace_reply("C123", "1.0", "thread-id", message="Taking a look..."))
assert posted == ["Taking a look..."]
def test_select_slack_context_messages_detects_username_mention() -> None: def test_select_slack_context_messages_detects_username_mention() -> None:
selected, mode = select_slack_context_messages( selected, mode = select_slack_context_messages(
[ [
@ -416,11 +452,9 @@ def test_get_slack_repo_config_repo_name_only_space_syntax(
assert repo == {"owner": "langchain-ai", "name": "open-swe"} assert repo == {"owner": "langchain-ai", "name": "open-swe"}
def test_process_slack_mention_creates_thread_followup_run( def _setup_slack_mention_fakes(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]
) -> None: ) -> None:
captured: dict[str, object] = {}
async def fake_add_slack_reaction(channel_id: str, message_ts: str, emoji: str) -> bool: async def fake_add_slack_reaction(channel_id: str, message_ts: str, emoji: str) -> bool:
captured["reaction"] = { captured["reaction"] = {
"channel_id": channel_id, "channel_id": channel_id,
@ -465,7 +499,7 @@ def test_process_slack_mention_creates_thread_followup_run(
return False return False
async def fake_post_slack_trace_reply( async def fake_post_slack_trace_reply(
channel_id: str, thread_ts: str, thread_id: str, message: str = "Working on it!" channel_id: str, thread_ts: str, thread_id: str, message: str | None = None
) -> None: ) -> None:
captured["trace_reply"] = { captured["trace_reply"] = {
"channel_id": channel_id, "channel_id": channel_id,
@ -503,6 +537,19 @@ def test_process_slack_mention_creates_thread_followup_run(
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply) monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess()) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
_setup_slack_mention_fakes(monkeypatch, captured)
async def fake_thread_exists(thread_id: str) -> bool:
captured["thread_exists_check"] = thread_id
return False
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
thread_ts = "1700000000.000100" thread_ts = "1700000000.000100"
event_ts = "1700000000.000200" event_ts = "1700000000.000200"
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts) expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
@ -521,6 +568,7 @@ def test_process_slack_mention_creates_thread_followup_run(
) )
) )
assert captured["thread_exists_check"] == expected_thread_id
assert captured["fetch_thread"] == {"channel_id": "C123", "thread_ts": thread_ts} assert captured["fetch_thread"] == {"channel_id": "C123", "thread_ts": thread_ts}
assert captured["active_thread_id"] == expected_thread_id assert captured["active_thread_id"] == expected_thread_id
assert captured["metadata_update"] == { assert captured["metadata_update"] == {
@ -531,7 +579,7 @@ def test_process_slack_mention_creates_thread_followup_run(
"channel_id": "C123", "channel_id": "C123",
"thread_ts": thread_ts, "thread_ts": thread_ts,
"thread_id": expected_thread_id, "thread_id": expected_thread_id,
"message": "Working on it!", "message": None,
} }
run_create = captured["run_create"] run_create = captured["run_create"]
@ -548,6 +596,44 @@ def test_process_slack_mention_creates_thread_followup_run(
assert "## Latest Mention Request\ncontinue on the branch" in prompt_block["text"] assert "## Latest Mention Request\ncontinue on the branch" in prompt_block["text"]
def test_process_slack_mention_skips_trace_reply_on_followup_mention(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Subsequent mentions in a Slack thread should not post 'Working on it!'."""
captured: dict[str, object] = {}
_setup_slack_mention_fakes(monkeypatch, captured)
async def fake_thread_exists(thread_id: str) -> bool:
captured["thread_exists_check"] = thread_id
return True
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
thread_ts = "1700000000.000100"
event_ts = "1700000000.000300"
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
asyncio.run(
webapp.process_slack_mention(
{
"channel_id": "C123",
"thread_ts": thread_ts,
"event_ts": event_ts,
"user_id": "U123",
"text": "<@UBOT> follow up question",
"bot_user_id": "UBOT",
},
{"owner": "langchain-ai", "name": "open-swe"},
)
)
assert captured["thread_exists_check"] == expected_thread_id
assert "trace_reply" not in captured
run_create = captured["run_create"]
assert isinstance(run_create, dict)
assert run_create["thread_id"] == expected_thread_id
def test_process_slack_mention_queues_active_thread_message( def test_process_slack_mention_queues_active_thread_message(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@ -604,6 +690,9 @@ def test_process_slack_mention_queues_active_thread_message(
async def fake_post_slack_trace_reply(*args, **kwargs) -> None: async def fake_post_slack_trace_reply(*args, **kwargs) -> None:
raise AssertionError("trace reply should not be posted for queued mid-run Slack messages") raise AssertionError("trace reply should not be posted for queued mid-run Slack messages")
async def fake_thread_exists(thread_id: str) -> bool:
return True
class _FakeRunsClient: class _FakeRunsClient:
async def create(self, *args, **kwargs) -> None: async def create(self, *args, **kwargs) -> None:
raise AssertionError("run should not be created for active Slack threads") raise AssertionError("run should not be created for active Slack threads")
@ -628,6 +717,7 @@ def test_process_slack_mention_queues_active_thread_message(
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
monkeypatch.setattr(webapp, "queue_message_for_thread", fake_queue_message_for_thread) monkeypatch.setattr(webapp, "queue_message_for_thread", fake_queue_message_for_thread)
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply) monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess()) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
thread_ts = "1700000000.000100" thread_ts = "1700000000.000100"