diff --git a/agent/prompt.py b/agent/prompt.py index 6726595b..575ef83d 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -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). #### `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. Key differences: *bold*, _italic_, ~strikethrough~, , bullet lists with "• ", ```code blocks```, > blockquotes. diff --git a/agent/tools/slack_thread_reply.py b/agent/tools/slack_thread_reply.py index 9210d14e..0dfef4b9 100644 --- a/agent/tools/slack_thread_reply.py +++ b/agent/tools/slack_thread_reply.py @@ -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]: """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. Key differences: *bold*, _italic_, ~strikethrough~, , bullet lists with "• ", ```code blocks```, > blockquotes. diff --git a/agent/utils/slack.py b/agent/utils/slack.py index fefb3c61..c03beb57 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -7,6 +7,7 @@ import hashlib import hmac import logging import os +import random import re import time from dataclasses import dataclass @@ -596,10 +597,25 @@ async def resolve_slack_links_in_context( 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( - 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: """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) if trace_url: await post_slack_thread_reply(channel_id, thread_ts, f"{message} <{trace_url}|View trace>") diff --git a/agent/webapp.py b/agent/webapp.py index d89abffa..6a36ab50 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -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) + is_first_mention = not await _thread_exists(thread_id) await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client) 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), 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( diff --git a/tests/test_slack_context.py b/tests/test_slack_context.py index ba2e7bcb..c0322b93 100644 --- a/tests/test_slack_context.py +++ b/tests/test_slack_context.py @@ -3,12 +3,15 @@ import asyncio import pytest from agent import webapp +from agent.utils import slack as slack_utils from agent.utils.slack import ( + TRACE_REPLY_PHRASES, convert_mentions_to_slack_format, format_slack_messages_for_prompt, looks_like_slack_pr_review_command, parse_github_pr_url, parse_slack_review_command, + post_slack_trace_reply, replace_bot_mention_with_username, select_slack_context_messages, 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?" +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: 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"} -def test_process_slack_mention_creates_thread_followup_run( - monkeypatch: pytest.MonkeyPatch, +def _setup_slack_mention_fakes( + monkeypatch: pytest.MonkeyPatch, captured: dict[str, object] ) -> None: - captured: dict[str, object] = {} - async def fake_add_slack_reaction(channel_id: str, message_ts: str, emoji: str) -> bool: captured["reaction"] = { "channel_id": channel_id, @@ -465,7 +499,7 @@ def test_process_slack_mention_creates_thread_followup_run( return False 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: captured["trace_reply"] = { "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, "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" event_ts = "1700000000.000200" 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["active_thread_id"] == expected_thread_id assert captured["metadata_update"] == { @@ -531,7 +579,7 @@ def test_process_slack_mention_creates_thread_followup_run( "channel_id": "C123", "thread_ts": thread_ts, "thread_id": expected_thread_id, - "message": "Working on it!", + "message": None, } 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"] +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( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -604,6 +690,9 @@ def test_process_slack_mention_queues_active_thread_message( async def fake_post_slack_trace_reply(*args, **kwargs) -> None: 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: async def create(self, *args, **kwargs) -> None: 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, "queue_message_for_thread", fake_queue_message_for_thread) 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()) thread_ts = "1700000000.000100"