mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
fix: update Slack trace reply on web handoff (#1630)
* fix: update Slack trace reply on web handoff Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: trigger web handoff on dashboard starts Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: format web handoff as contextual fragment Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: preserve trace_message_ts when overwriting Slack run mapping When store_slack_run_mapping is called without trace_message_ts (e.g. on follow-up Slack mentions), it was unconditionally overwriting the thread-level mapping and clobbering the timestamp captured from the initial trace reply. After that, _notify_slack_web_handoff could not find the original message, so a subsequent move to Web silently skipped the Slack trace update. Now, when trace_message_ts is not passed, the existing thread mapping is read first and its trace_message_ts is preserved. * style: ruff format --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
parent
4cd5fa5ce5
commit
92dbf6f932
9 changed files with 661 additions and 18 deletions
|
|
@ -17,8 +17,10 @@ from fastapi import HTTPException
|
|||
from langchain_core.messages.content import create_image_block
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..utils.dashboard_handoff import DASHBOARD_HANDOFF_INSTRUCTION
|
||||
from ..utils.langsmith import get_langsmith_trace_url
|
||||
from ..utils.sandbox import create_sandbox
|
||||
from ..utils.slack import lookup_slack_thread_run_mapping, update_slack_trace_reply_for_web_handoff
|
||||
from ..utils.thread_ops import (
|
||||
get_thread_active_status,
|
||||
langgraph_client,
|
||||
|
|
@ -1073,6 +1075,17 @@ def _prefix_message_content(content: Any, prefix: str) -> Any:
|
|||
return content
|
||||
|
||||
|
||||
def _prepend_message_content_block(content: Any, text: str) -> Any:
|
||||
block = {"type": "text", "text": text}
|
||||
if isinstance(content, str):
|
||||
return [block, {"type": "text", "text": content}]
|
||||
if isinstance(content, list):
|
||||
return [block, *content]
|
||||
if content is None:
|
||||
return [block]
|
||||
return content
|
||||
|
||||
|
||||
def _command_prompt_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
|
|
@ -1203,7 +1216,10 @@ async def _enrich_run_start_command(
|
|||
_validate_command_images(content, model_id=run_model)
|
||||
prefix = _attribution_prefix(metadata, login, email)
|
||||
if prefix:
|
||||
_set_command_last_message_content(params, _prefix_message_content(content, prefix))
|
||||
content = _prefix_message_content(content, prefix)
|
||||
if metadata.get("source") == "slack":
|
||||
content = _prepend_message_content_block(content, DASHBOARD_HANDOFF_INSTRUCTION)
|
||||
_set_command_last_message_content(params, content)
|
||||
metadata_update: dict[str, Any] = {"plan_mode": plan_mode_requested}
|
||||
if command_images and run_model and run_effort:
|
||||
overrides["agent_model_id"] = run_model
|
||||
|
|
@ -1246,6 +1262,43 @@ async def _enrich_run_start_command(
|
|||
return command
|
||||
|
||||
|
||||
def _slack_thread_context(metadata: dict[str, Any]) -> dict[str, Any] | None:
|
||||
source_context = metadata.get("source_context")
|
||||
if not isinstance(source_context, dict):
|
||||
return None
|
||||
slack_thread = source_context.get("slack_thread")
|
||||
return slack_thread if isinstance(slack_thread, dict) else None
|
||||
|
||||
|
||||
async def _notify_slack_web_handoff(thread_id: str, metadata: dict[str, Any], client: Any) -> None:
|
||||
if metadata.get("source") != "slack":
|
||||
return
|
||||
slack_thread = _slack_thread_context(metadata)
|
||||
if not slack_thread:
|
||||
return
|
||||
channel_id = slack_thread.get("channel_id")
|
||||
thread_ts = slack_thread.get("thread_ts")
|
||||
if not isinstance(channel_id, str) or not channel_id:
|
||||
return
|
||||
if not isinstance(thread_ts, str) or not thread_ts:
|
||||
return
|
||||
|
||||
trace_message_ts = slack_thread.get("trace_message_ts")
|
||||
if not isinstance(trace_message_ts, str) or not trace_message_ts:
|
||||
mapping = await lookup_slack_thread_run_mapping(client, channel_id, thread_ts)
|
||||
if isinstance(mapping, dict):
|
||||
candidate = mapping.get("trace_message_ts")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
trace_message_ts = candidate
|
||||
if not isinstance(trace_message_ts, str) or not trace_message_ts:
|
||||
logger.info(
|
||||
"Skipping Slack web handoff update for thread %s: missing trace message ts", thread_id
|
||||
)
|
||||
return
|
||||
|
||||
await update_slack_trace_reply_for_web_handoff(channel_id, trace_message_ts, thread_id)
|
||||
|
||||
|
||||
async def send_dashboard_message(
|
||||
thread_id: str, login: str, body: ThreadMessageBody, *, email: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -1261,6 +1314,7 @@ async def send_dashboard_message(
|
|||
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"
|
||||
now_ms = _now_ms()
|
||||
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
|
||||
handoff_metadata = dict(metadata)
|
||||
metadata_update: dict[str, Any] = {
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"updated_at_ms": now_ms,
|
||||
|
|
@ -1293,6 +1347,10 @@ async def send_dashboard_message(
|
|||
queued = await queue_message_for_thread(thread_id, queue_payload)
|
||||
if not queued:
|
||||
raise HTTPException(502, "failed to queue follow-up message")
|
||||
try:
|
||||
await _notify_slack_web_handoff(thread_id, handoff_metadata, client)
|
||||
except Exception:
|
||||
logger.exception("Failed to update Slack message for dashboard handoff on %s", thread_id)
|
||||
thread = await client.threads.get(thread_id)
|
||||
return _thread_summary(
|
||||
thread if isinstance(thread, dict) else {"thread_id": thread_id, "metadata": metadata}
|
||||
|
|
@ -1812,11 +1870,20 @@ async def proxy_dashboard_thread_commands(
|
|||
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
|
||||
response = await client.post(url, content=outgoing, headers=headers)
|
||||
|
||||
if (
|
||||
parsed.get("method") == "run.start"
|
||||
and response.status_code in {200, 202, 204}
|
||||
and response.content
|
||||
):
|
||||
run_start_succeeded = parsed.get("method") == "run.start" and response.status_code in {
|
||||
200,
|
||||
202,
|
||||
204,
|
||||
}
|
||||
if run_start_succeeded and not creating:
|
||||
try:
|
||||
await _notify_slack_web_handoff(thread_id, metadata, langgraph_client())
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to update Slack message for dashboard handoff on %s", thread_id
|
||||
)
|
||||
|
||||
if run_start_succeeded and response.content:
|
||||
try:
|
||||
payload = json.loads(response.content)
|
||||
except json.JSONDecodeError:
|
||||
|
|
|
|||
|
|
@ -18,19 +18,15 @@ from langgraph.store.base import BaseStore
|
|||
from langgraph_sdk import get_client
|
||||
|
||||
from ..dashboard.options import model_supports_images
|
||||
from ..utils.dashboard_handoff import ( # noqa: F401
|
||||
DASHBOARD_HANDOFF_INSTRUCTION,
|
||||
DASHBOARD_HANDOFF_MARKER,
|
||||
)
|
||||
from ..utils.http import DEFAULT_HTTP_TIMEOUT
|
||||
from ..utils.multimodal import fetch_image_block, vision_not_supported_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DASHBOARD_HANDOFF_MARKER = "[Open SWE Web handoff]"
|
||||
DASHBOARD_HANDOFF_INSTRUCTION = (
|
||||
f"{DASHBOARD_HANDOFF_MARKER} This follow-up was sent from Web. "
|
||||
"The conversation has moved to Web, so answer in the dashboard stream with a normal "
|
||||
"assistant message. Do not call slack_thread_reply unless a later Slack message explicitly "
|
||||
"moves the conversation back to Slack."
|
||||
)
|
||||
|
||||
|
||||
class LinearNotifyState(AgentState):
|
||||
"""Extended agent state for tracking Linear notifications."""
|
||||
|
|
|
|||
11
agent/utils/dashboard_handoff.py
Normal file
11
agent/utils/dashboard_handoff.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
DASHBOARD_HANDOFF_OPEN_TAG = "<open_swe_web_handoff>"
|
||||
DASHBOARD_HANDOFF_CLOSE_TAG = "</open_swe_web_handoff>"
|
||||
DASHBOARD_HANDOFF_MARKER = DASHBOARD_HANDOFF_OPEN_TAG
|
||||
DASHBOARD_HANDOFF_BODY = (
|
||||
"This follow-up was sent from Web. The conversation has moved to Web, so answer in "
|
||||
"the dashboard stream with a normal assistant message. Do not call slack_thread_reply "
|
||||
"unless a later Slack message explicitly moves the conversation back to Slack."
|
||||
)
|
||||
DASHBOARD_HANDOFF_INSTRUCTION = (
|
||||
f"{DASHBOARD_HANDOFF_OPEN_TAG}\n{DASHBOARD_HANDOFF_BODY}\n{DASHBOARD_HANDOFF_CLOSE_TAG}"
|
||||
)
|
||||
|
|
@ -346,6 +346,56 @@ async def post_slack_thread_reply_with_ts(
|
|||
return None, f"http_error: {type(exc).__name__}"
|
||||
|
||||
|
||||
async def update_slack_message(
|
||||
channel_id: str,
|
||||
message_ts: str,
|
||||
text: str,
|
||||
*,
|
||||
unfurl_links: bool = True,
|
||||
unfurl_media: bool = True,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Update a Slack message and return success plus any Slack error."""
|
||||
if not SLACK_BOT_TOKEN:
|
||||
return False, "missing_slack_bot_token"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"ts": message_ts,
|
||||
"text": text,
|
||||
"unfurl_links": unfurl_links,
|
||||
"unfurl_media": unfurl_media,
|
||||
}
|
||||
if blocks:
|
||||
payload["blocks"] = blocks
|
||||
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as http_client:
|
||||
try:
|
||||
response = await http_client.post(
|
||||
f"{SLACK_API_BASE_URL}/chat.update",
|
||||
headers=_slack_headers(),
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
logger.warning("Slack chat.update rate limited (retry-after=%s)", retry_after)
|
||||
if retry_after:
|
||||
return False, f"rate_limited: {retry_after}"
|
||||
return False, "rate_limited"
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
error = data.get("error")
|
||||
logger.warning("Slack chat.update failed: %s", error)
|
||||
if error == "ratelimited":
|
||||
return False, "rate_limited"
|
||||
return False, error
|
||||
return True, None
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("Slack chat.update request failed")
|
||||
return False, f"http_error: {type(exc).__name__}"
|
||||
|
||||
|
||||
async def post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool:
|
||||
"""Post a reply in a Slack thread."""
|
||||
message_ts, _ = await post_slack_thread_reply_with_ts(channel_id, thread_ts, text)
|
||||
|
|
@ -777,17 +827,24 @@ TRACE_REPLY_TIPS: tuple[str, ...] = (
|
|||
"Ask me to search the web — I have a `web_search` tool for finding docs, examples, and GitHub repos mid-task.",
|
||||
"I can read, update, and create Linear issues directly — useful for filing follow-up tickets or linking work back to a project.",
|
||||
)
|
||||
TRACE_REPLY_WEB_HANDOFF_NOTICE = (
|
||||
"Conversation moved to Web — use the `Open in Web` link above for follow-ups."
|
||||
)
|
||||
|
||||
|
||||
def _format_trace_reply(trace_url: str | None, dashboard_url: str | None) -> str:
|
||||
"""Format the initial trace reply with a randomly selected tip."""
|
||||
tip = random.choice(TRACE_REPLY_TIPS)
|
||||
def _format_trace_reply(
|
||||
trace_url: str | None, dashboard_url: str | None, *, moved_to_web: bool = False
|
||||
) -> str:
|
||||
"""Format the initial trace reply with status text."""
|
||||
links = []
|
||||
if trace_url:
|
||||
links.append(f"<{trace_url}|View trace>")
|
||||
if dashboard_url:
|
||||
links.append(f"<{dashboard_url}|Open in Web>")
|
||||
head = f"{' • '.join(links)}\n" if links else ""
|
||||
if moved_to_web:
|
||||
return f"{head}_{TRACE_REPLY_WEB_HANDOFF_NOTICE}_"
|
||||
tip = random.choice(TRACE_REPLY_TIPS)
|
||||
return f"{head}_Tip: {tip}_"
|
||||
|
||||
|
||||
|
|
@ -807,6 +864,29 @@ async def post_slack_trace_reply(
|
|||
return message_ts
|
||||
|
||||
|
||||
async def update_slack_trace_reply_for_web_handoff(
|
||||
channel_id: str, message_ts: str, thread_id: str
|
||||
) -> bool:
|
||||
"""Update the initial Slack trace reply after a dashboard handoff."""
|
||||
trace_url = get_langsmith_trace_url(thread_id)
|
||||
dashboard_url = dashboard_thread_url(thread_id)
|
||||
ok, error = await update_slack_message(
|
||||
channel_id,
|
||||
message_ts,
|
||||
_format_trace_reply(trace_url, dashboard_url, moved_to_web=True),
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning(
|
||||
"Failed to update Slack trace reply for web handoff: channel=%s ts=%s error=%s",
|
||||
channel_id,
|
||||
message_ts,
|
||||
error,
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
_SLACK_RUN_MAP_NAMESPACE = "slack_run_map"
|
||||
_THREAD_RUN_KEY_PREFIX = "thread:"
|
||||
_MESSAGE_RUN_KEY_PREFIX = "message:"
|
||||
|
|
@ -830,12 +910,21 @@ async def store_slack_run_mapping(
|
|||
*,
|
||||
message_ts: str | None = None,
|
||||
triggering_user_id: str | None = None,
|
||||
trace_message_ts: str | None = None,
|
||||
) -> None:
|
||||
"""Persist Slack thread/message to LangGraph run mapping."""
|
||||
namespace = (_SLACK_RUN_MAP_NAMESPACE, channel_id)
|
||||
if not trace_message_ts:
|
||||
existing = await lookup_slack_thread_run_mapping(langgraph_client, channel_id, thread_ts)
|
||||
if isinstance(existing, dict):
|
||||
candidate = existing.get("trace_message_ts")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
trace_message_ts = candidate
|
||||
value: dict[str, Any] = {"run_id": run_id, "thread_ts": thread_ts}
|
||||
if triggering_user_id:
|
||||
value["triggering_user_id"] = triggering_user_id
|
||||
if trace_message_ts:
|
||||
value["trace_message_ts"] = trace_message_ts
|
||||
try:
|
||||
await langgraph_client.store.put_item(
|
||||
namespace, f"{_THREAD_RUN_KEY_PREFIX}{thread_ts}", value
|
||||
|
|
@ -876,12 +965,16 @@ async def store_slack_message_run_mapping(
|
|||
)
|
||||
return
|
||||
triggering_user_id: str | None = None
|
||||
trace_message_ts: str | None = None
|
||||
if isinstance(item, dict):
|
||||
value = item.get("value")
|
||||
if isinstance(value, dict):
|
||||
candidate = value.get("triggering_user_id")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
triggering_user_id = candidate
|
||||
candidate = value.get("trace_message_ts")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
trace_message_ts = candidate
|
||||
await store_slack_run_mapping(
|
||||
langgraph_client,
|
||||
channel_id,
|
||||
|
|
@ -889,6 +982,7 @@ async def store_slack_message_run_mapping(
|
|||
run_id,
|
||||
message_ts=message_ts,
|
||||
triggering_user_id=triggering_user_id,
|
||||
trace_message_ts=trace_message_ts,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
@ -898,6 +992,30 @@ async def store_slack_message_run_mapping(
|
|||
)
|
||||
|
||||
|
||||
async def lookup_slack_thread_run_mapping(
|
||||
langgraph_client: LangGraphClient,
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the stored mapping value for a Slack thread, or None."""
|
||||
namespace = (_SLACK_RUN_MAP_NAMESPACE, channel_id)
|
||||
try:
|
||||
item = await langgraph_client.store.get_item(
|
||||
namespace, f"{_THREAD_RUN_KEY_PREFIX}{thread_ts}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to look up Slack thread run mapping for channel=%s thread=%s",
|
||||
channel_id,
|
||||
thread_ts,
|
||||
)
|
||||
return None
|
||||
if not item:
|
||||
return None
|
||||
value = item.get("value")
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def lookup_slack_run_mapping(
|
||||
langgraph_client: LangGraphClient,
|
||||
channel_id: str,
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ async def _process_slack_mention_impl(
|
|||
run_id,
|
||||
message_ts=trace_message_ts,
|
||||
triggering_user_id=user_id,
|
||||
trace_message_ts=trace_message_ts,
|
||||
)
|
||||
else:
|
||||
webapp.logger.info(
|
||||
|
|
|
|||
|
|
@ -498,6 +498,98 @@ async def test_enrich_run_start_command_attributes_non_owner_message(monkeypatch
|
|||
assert last["content"] == "@teammate: fix the bug"
|
||||
|
||||
|
||||
async def test_enrich_run_start_command_adds_web_handoff_for_slack_thread(monkeypatch) -> None:
|
||||
class FakeThreads:
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
threads = FakeThreads()
|
||||
|
||||
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||
return {}
|
||||
|
||||
async def fake_ensure_token(login: str) -> None:
|
||||
pass
|
||||
|
||||
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||
return f"{login}@example.com"
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||
|
||||
command = {
|
||||
"method": "run.start",
|
||||
"params": {"input": {"messages": [{"role": "user", "content": "continue here"}]}},
|
||||
}
|
||||
|
||||
enriched = await thread_api._enrich_run_start_command(
|
||||
"tid",
|
||||
"teammate",
|
||||
command,
|
||||
metadata={"source": "slack", "github_login": "owner"},
|
||||
email="teammate@example.com",
|
||||
)
|
||||
|
||||
content = enriched["params"]["input"]["messages"][-1]["content"]
|
||||
assert content[0] == {"type": "text", "text": thread_api.DASHBOARD_HANDOFF_INSTRUCTION}
|
||||
assert content[1] == {"type": "text", "text": "@teammate: continue here"}
|
||||
assert content[0]["text"].startswith("<open_swe_web_handoff>\n")
|
||||
assert content[0]["text"].endswith("\n</open_swe_web_handoff>")
|
||||
|
||||
|
||||
async def test_enrich_run_start_command_adds_web_handoff_before_image_blocks(monkeypatch) -> None:
|
||||
class FakeThreads:
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||
pass
|
||||
|
||||
class FakeClient:
|
||||
threads = FakeThreads()
|
||||
|
||||
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||
return {}
|
||||
|
||||
async def fake_ensure_token(login: str) -> None:
|
||||
pass
|
||||
|
||||
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||
return f"{login}@example.com"
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||
|
||||
command = {
|
||||
"method": "run.start",
|
||||
"params": {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "continue here"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
enriched = await thread_api._enrich_run_start_command(
|
||||
"tid",
|
||||
"teammate",
|
||||
command,
|
||||
metadata={"source": "slack", "github_login": "owner"},
|
||||
email="teammate@example.com",
|
||||
)
|
||||
|
||||
content = enriched["params"]["input"]["messages"][-1]["content"]
|
||||
assert content[0] == {"type": "text", "text": thread_api.DASHBOARD_HANDOFF_INSTRUCTION}
|
||||
assert content[1] == {"type": "text", "text": "@teammate:"}
|
||||
assert content[2] == {"type": "text", "text": "continue here"}
|
||||
|
||||
|
||||
async def test_enrich_run_start_command_does_not_attribute_owner_message(monkeypatch) -> None:
|
||||
class FakeThreads:
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||
|
|
@ -602,6 +694,100 @@ async def test_enrich_run_start_command_allowlists_client_configurable(monkeypat
|
|||
assert updates[-1]["model"] == _VISION_MODEL
|
||||
|
||||
|
||||
async def test_proxy_run_start_from_slack_thread_updates_trace_reply(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeThreads:
|
||||
async def get(self, thread_id: str) -> dict[str, object]:
|
||||
assert thread_id == "tid"
|
||||
return {
|
||||
"thread_id": "tid",
|
||||
"metadata": {
|
||||
"source": "slack",
|
||||
"github_login": "octocat",
|
||||
"source_context": {
|
||||
"slack_thread": {
|
||||
"channel_id": "C1",
|
||||
"thread_ts": "123.45",
|
||||
"trace_message_ts": "123.46",
|
||||
}
|
||||
},
|
||||
},
|
||||
"status": "idle",
|
||||
}
|
||||
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None:
|
||||
captured.setdefault("updates", []).append(metadata)
|
||||
|
||||
class FakeClient:
|
||||
threads = FakeThreads()
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
content = b'{"run_id":"run-1"}'
|
||||
headers = {"content-type": "application/json"}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *a: object, **kw: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: object) -> None:
|
||||
pass
|
||||
|
||||
async def post(self, url: str, *, content: bytes, headers: dict[str, str]) -> FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["outgoing"] = json.loads(content)
|
||||
return FakeResponse()
|
||||
|
||||
async def fake_get_profile(login: str) -> dict[str, object]:
|
||||
return {}
|
||||
|
||||
async def fake_ensure_token(login: str) -> None:
|
||||
pass
|
||||
|
||||
async def fake_resolve_email(login: str, profile: dict[str, object]) -> str:
|
||||
return f"{login}@example.com"
|
||||
|
||||
async def fake_update_trace_reply(channel_id: str, message_ts: str, thread_id: str) -> bool:
|
||||
captured["handoff_update"] = {
|
||||
"channel_id": channel_id,
|
||||
"message_ts": message_ts,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
|
||||
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||
monkeypatch.setattr(thread_api.httpx, "AsyncClient", FakeAsyncClient)
|
||||
monkeypatch.setattr(
|
||||
thread_api, "update_slack_trace_reply_for_web_handoff", fake_update_trace_reply
|
||||
)
|
||||
|
||||
status, body, _ = await thread_api.proxy_dashboard_thread_commands(
|
||||
"tid",
|
||||
"octocat",
|
||||
b'{"method":"run.start","params":{"input":{"messages":[{"role":"user","content":"continue here"}]}}}',
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert body == b'{"run_id":"run-1"}'
|
||||
outgoing = captured["outgoing"]
|
||||
assert isinstance(outgoing, dict)
|
||||
content = outgoing["params"]["input"]["messages"][-1]["content"]
|
||||
assert content[0] == {"type": "text", "text": thread_api.DASHBOARD_HANDOFF_INSTRUCTION}
|
||||
assert content[1] == {"type": "text", "text": "continue here"}
|
||||
assert captured["handoff_update"] == {
|
||||
"channel_id": "C1",
|
||||
"message_ts": "123.46",
|
||||
"thread_id": "tid",
|
||||
}
|
||||
|
||||
|
||||
async def test_proxy_commands_rejects_non_object_body(monkeypatch) -> None:
|
||||
class FakeThreads:
|
||||
async def get(self, thread_id: str) -> dict[str, object]:
|
||||
|
|
|
|||
|
|
@ -30,10 +30,25 @@ class _FakeRuns:
|
|||
return {"run_id": "run-1"}
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(
|
||||
self, items: dict[tuple[tuple[str, ...], str], dict[str, Any]] | None = None
|
||||
) -> None:
|
||||
self.items = items or {}
|
||||
|
||||
async def get_item(self, namespace: tuple[str, ...], key: str) -> dict[str, Any] | None:
|
||||
return self.items.get((namespace, key))
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, metadata: dict[str, Any]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
metadata: dict[str, Any],
|
||||
store_items: dict[tuple[tuple[str, ...], str], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.threads = _FakeThreads(metadata)
|
||||
self.runs = _FakeRuns()
|
||||
self.store = _FakeStore(store_items)
|
||||
|
||||
|
||||
async def _inactive_thread(thread_id: str) -> bool:
|
||||
|
|
@ -162,6 +177,104 @@ async def test_dashboard_followup_on_busy_thread_queues_dashboard_handoff(
|
|||
assert queued_messages == [{"text": "continue in web", "source": "dashboard"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_followup_on_busy_slack_thread_updates_trace_reply(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
metadata = {
|
||||
"source": "slack",
|
||||
"github_login": "octocat",
|
||||
"triggering_user_email": "octocat@example.com",
|
||||
"source_context": {
|
||||
"slack_thread": {
|
||||
"channel_id": "C1",
|
||||
"thread_ts": "123.45",
|
||||
"trace_message_ts": "123.46",
|
||||
}
|
||||
},
|
||||
}
|
||||
client = _FakeClient(metadata)
|
||||
queued_messages: list[object] = []
|
||||
handoff_updates: list[dict[str, str]] = []
|
||||
|
||||
async def fake_queue_message_for_thread(thread_id: str, message_content: object) -> bool:
|
||||
queued_messages.append(message_content)
|
||||
return True
|
||||
|
||||
async def fake_update_trace_reply(channel_id: str, message_ts: str, thread_id: str) -> bool:
|
||||
handoff_updates.append(
|
||||
{"channel_id": channel_id, "message_ts": message_ts, "thread_id": thread_id}
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||
monkeypatch.setattr(
|
||||
thread_api, "update_slack_trace_reply_for_web_handoff", fake_update_trace_reply
|
||||
)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
email="octocat@example.com",
|
||||
)
|
||||
|
||||
assert queued_messages == [{"text": "continue in web", "source": "dashboard"}]
|
||||
assert handoff_updates == [
|
||||
{"channel_id": "C1", "message_ts": "123.46", "thread_id": "thread-1"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_followup_uses_stored_trace_reply_timestamp(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
metadata = {
|
||||
"source": "slack",
|
||||
"github_login": "octocat",
|
||||
"triggering_user_email": "octocat@example.com",
|
||||
"source_context": {"slack_thread": {"channel_id": "C1", "thread_ts": "123.45"}},
|
||||
}
|
||||
client = _FakeClient(
|
||||
metadata,
|
||||
{
|
||||
(("slack_run_map", "C1"), "thread:123.45"): {
|
||||
"value": {"run_id": "run-1", "thread_ts": "123.45", "trace_message_ts": "123.46"}
|
||||
}
|
||||
},
|
||||
)
|
||||
handoff_updates: list[dict[str, str]] = []
|
||||
|
||||
async def fake_queue_message_for_thread(thread_id: str, message_content: object) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_update_trace_reply(channel_id: str, message_ts: str, thread_id: str) -> bool:
|
||||
handoff_updates.append(
|
||||
{"channel_id": channel_id, "message_ts": message_ts, "thread_id": thread_id}
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||
monkeypatch.setattr(thread_api, "get_thread_active_status", _active_thread)
|
||||
monkeypatch.setattr(thread_api, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||
monkeypatch.setattr(
|
||||
thread_api, "update_slack_trace_reply_for_web_handoff", fake_update_trace_reply
|
||||
)
|
||||
|
||||
await thread_api.send_dashboard_message(
|
||||
"thread-1",
|
||||
"octocat",
|
||||
thread_api.ThreadMessageBody(content="continue in web"),
|
||||
email="octocat@example.com",
|
||||
)
|
||||
|
||||
assert handoff_updates == [
|
||||
{"channel_id": "C1", "message_ts": "123.46", "thread_id": "thread-1"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_followup_on_busy_thread_queues_images(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
|
|||
|
|
@ -149,6 +149,30 @@ async def test_post_slack_thread_reply_does_not_call_set_status(
|
|||
assert client_cm.post.call_args.args[0].endswith("/chat.postMessage")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_slack_message_calls_chat_update(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test")
|
||||
|
||||
client_cm = _async_client_cm(_ok_response())
|
||||
with patch.object(slack_utils.httpx, "AsyncClient", return_value=client_cm):
|
||||
result = await slack_utils.update_slack_message(
|
||||
"C1", "1.1", "moved", unfurl_links=False, unfurl_media=False
|
||||
)
|
||||
|
||||
assert result == (True, None)
|
||||
assert client_cm.post.await_count == 1
|
||||
assert client_cm.post.call_args.args[0].endswith("/chat.update")
|
||||
assert client_cm.post.call_args.kwargs["json"] == {
|
||||
"channel": "C1",
|
||||
"ts": "1.1",
|
||||
"text": "moved",
|
||||
"unfurl_links": False,
|
||||
"unfurl_media": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_slack_thread_reply_with_ts_returns_missing_token_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ class _FakeClient:
|
|||
self.threads = threads_client
|
||||
|
||||
|
||||
class _FakeSlackMappingStore:
|
||||
def __init__(self) -> None:
|
||||
self.items: dict[tuple[tuple[str, ...], str], dict] = {}
|
||||
|
||||
async def put_item(self, namespace: tuple[str, ...], key: str, value: dict) -> None:
|
||||
self.items[(namespace, key)] = {"value": value}
|
||||
|
||||
async def get_item(self, namespace: tuple[str, ...], key: str) -> dict | None:
|
||||
return self.items.get((namespace, key))
|
||||
|
||||
|
||||
class _FakeSlackMappingClient:
|
||||
def __init__(self) -> None:
|
||||
self.store = _FakeSlackMappingStore()
|
||||
|
||||
|
||||
def test_generate_thread_id_from_slack_thread_is_deterministic() -> None:
|
||||
channel_id = "C12345"
|
||||
thread_ts = "1730900000.123456"
|
||||
|
|
@ -54,6 +70,63 @@ def test_generate_thread_id_from_slack_thread_is_deterministic() -> None:
|
|||
assert len(first) == 36
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_run_mapping_preserves_trace_message_ts() -> None:
|
||||
client = _FakeSlackMappingClient()
|
||||
|
||||
await slack_utils.store_slack_run_mapping(
|
||||
client,
|
||||
"C123",
|
||||
"1.0",
|
||||
"run-1",
|
||||
message_ts="1.1",
|
||||
triggering_user_id="U123",
|
||||
trace_message_ts="1.1",
|
||||
)
|
||||
await slack_utils.store_slack_message_run_mapping(client, "C123", "1.0", "1.2")
|
||||
|
||||
thread_mapping = await slack_utils.lookup_slack_thread_run_mapping(client, "C123", "1.0")
|
||||
message_mapping = await slack_utils.lookup_slack_run_mapping(client, "C123", "1.2")
|
||||
assert thread_mapping is not None
|
||||
assert thread_mapping["trace_message_ts"] == "1.1"
|
||||
assert thread_mapping["triggering_user_id"] == "U123"
|
||||
assert message_mapping is not None
|
||||
assert message_mapping["trace_message_ts"] == "1.1"
|
||||
assert message_mapping["message_ts"] == "1.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_run_mapping_preserves_trace_message_ts_on_followup_mention() -> None:
|
||||
"""A subsequent Slack mention without trace_message_ts must not clobber the stored timestamp."""
|
||||
client = _FakeSlackMappingClient()
|
||||
|
||||
# First mention stores the trace message ts.
|
||||
await slack_utils.store_slack_run_mapping(
|
||||
client,
|
||||
"C123",
|
||||
"1.0",
|
||||
"run-1",
|
||||
message_ts="1.1",
|
||||
triggering_user_id="U123",
|
||||
trace_message_ts="1.1",
|
||||
)
|
||||
|
||||
# Follow-up mention (non-first) stores a new run_id without trace_message_ts.
|
||||
await slack_utils.store_slack_run_mapping(
|
||||
client,
|
||||
"C123",
|
||||
"1.0",
|
||||
"run-2",
|
||||
triggering_user_id="U456",
|
||||
)
|
||||
|
||||
thread_mapping = await slack_utils.lookup_slack_thread_run_mapping(client, "C123", "1.0")
|
||||
assert thread_mapping is not None
|
||||
assert thread_mapping["run_id"] == "run-2"
|
||||
assert thread_mapping["trace_message_ts"] == "1.1"
|
||||
assert thread_mapping["triggering_user_id"] == "U456"
|
||||
|
||||
|
||||
def test_select_slack_context_messages_uses_thread_start_when_no_prior_mention() -> None:
|
||||
bot_user_id = "UBOT"
|
||||
messages = [
|
||||
|
|
@ -248,6 +321,60 @@ def test_post_slack_trace_reply_includes_trace_link_and_tip(
|
|||
assert posted[0]["unfurl_media"] is False
|
||||
|
||||
|
||||
def test_format_trace_reply_can_show_web_handoff_notice() -> None:
|
||||
text = slack_utils._format_trace_reply(
|
||||
"https://smith/x", "https://app.example.com/agents/thread-id", moved_to_web=True
|
||||
)
|
||||
|
||||
head, _, notice_line = text.partition("\n")
|
||||
assert (
|
||||
head
|
||||
== "<https://smith/x|View trace> • <https://app.example.com/agents/thread-id|Open in Web>"
|
||||
)
|
||||
assert "Conversation moved to Web" in notice_line
|
||||
assert "Tip:" not in notice_line
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_slack_trace_reply_for_web_handoff_updates_existing_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
updated: list[dict] = []
|
||||
|
||||
async def fake_update_slack_message(
|
||||
channel_id: str,
|
||||
message_ts: str,
|
||||
text: str,
|
||||
*,
|
||||
unfurl_links: bool = True,
|
||||
unfurl_media: bool = True,
|
||||
) -> tuple[bool, str | None]:
|
||||
updated.append(
|
||||
{
|
||||
"channel_id": channel_id,
|
||||
"message_ts": message_ts,
|
||||
"text": text,
|
||||
"unfurl_links": unfurl_links,
|
||||
"unfurl_media": unfurl_media,
|
||||
}
|
||||
)
|
||||
return True, None
|
||||
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
|
||||
monkeypatch.setattr(slack_utils, "update_slack_message", fake_update_slack_message)
|
||||
monkeypatch.setattr(slack_utils, "get_langsmith_trace_url", lambda thread_id: "https://smith/x")
|
||||
|
||||
ok = await slack_utils.update_slack_trace_reply_for_web_handoff("C123", "1.1", "thread-id")
|
||||
|
||||
assert ok is True
|
||||
assert len(updated) == 1
|
||||
assert updated[0]["channel_id"] == "C123"
|
||||
assert updated[0]["message_ts"] == "1.1"
|
||||
assert "Conversation moved to Web" in updated[0]["text"]
|
||||
assert updated[0]["unfurl_links"] is False
|
||||
assert updated[0]["unfurl_media"] is False
|
||||
|
||||
|
||||
def test_post_slack_trace_reply_can_skip_web_link(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue