mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 19:22:13 +00:00
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:
parent
bb836b58bc
commit
5a01aff1c7
5 changed files with 128 additions and 9 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue