From 7a9785c9d318f65b157f3beef356a4a0e3600fc9 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Mon, 1 Jun 2026 15:47:53 -0700 Subject: [PATCH] fix: resolve dashboard run email from GitHub mapping, not OAuth profile (#1372) When sending a message to a thread from the Agents chat UI, the run used the OAuth profile email (which can be a personal account that isn't an org member). For Slack-originated threads resumed from the UI, auth routes through the email-based path and fails ("Could not find a LangSmith account"). Resolve the run email via the GitHub->email mapping first, falling back to the profile email only when no mapping exists, matching the github-source auth path. --- agent/dashboard/thread_api.py | 16 +++++++-- tests/test_dashboard_run_email.py | 56 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/test_dashboard_run_email.py diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 1182bc76..98c2c868 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -21,6 +21,7 @@ from .message_adapter import state_messages_to_ui from .options import SUPPORTED_MODEL_IDS, model_supports_effort from .profiles import OAUTH_TOKENS_NAMESPACE, get_profile, get_valid_access_token from .profiles import _get_value as get_oauth_record +from .user_mappings import email_for_login logger = logging.getLogger(__name__) @@ -36,6 +37,17 @@ def _agent_version_metadata() -> dict[str, str]: return {"LANGSMITH_AGENT_VERSION": revision} if revision else {} +async def _resolve_run_email(login: str, profile: dict[str, Any]) -> str | None: + """Email used for GitHub/LangSmith auth on a run. + + Prefers the admin/self GitHub→email mapping (the work email known to + the org) over the OAuth profile email, which may be a personal account + that isn't an org member. + """ + mapped = await email_for_login(login) + return mapped or profile.get("email") + + class ThreadCreateBody(BaseModel): prompt: str = Field(min_length=1, max_length=20_000) repo: str | None = None @@ -320,7 +332,7 @@ async def _start_agent_run( "source": _DASHBOARD_SOURCE, "github_login": login, "repo": repo_config, - "user_email": profile.get("email"), + "user_email": await _resolve_run_email(login, profile), } if chosen_model and chosen_effort: configurable["agent_model_id"] = chosen_model @@ -400,7 +412,7 @@ async def send_dashboard_message( "source": thread_source, "github_login": login, "repo": {"owner": owner, "name": name}, - "user_email": profile.get("email"), + "user_email": await _resolve_run_email(login, profile), } source_context = metadata.get("source_context") if isinstance(source_context, dict): diff --git a/tests/test_dashboard_run_email.py b/tests/test_dashboard_run_email.py new file mode 100644 index 00000000..6c2eb779 --- /dev/null +++ b/tests/test_dashboard_run_email.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agent.dashboard import thread_api +from agent.dashboard import user_mappings as um + + +class _FakeStore: + def __init__(self) -> None: + self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {} + + async def get_item(self, namespace: list[str], key: str): + value = self.items.get((tuple(namespace), key)) + return {"value": value} if value is not None else None + + async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None: + self.items[(tuple(namespace), key)] = value + + async def search_items(self, namespace: list[str], *, limit: int = 1000): + ns = tuple(namespace) + items = [{"value": v} for (n, _k), v in self.items.items() if n == ns] + return {"items": items[:limit]} + + +class _FakeClient: + def __init__(self, store: _FakeStore) -> None: + self.store = store + + +@pytest.fixture() +def fake_store(monkeypatch: pytest.MonkeyPatch) -> _FakeStore: + store = _FakeStore() + monkeypatch.setattr(um, "_client", lambda: _FakeClient(store)) + um.clear_cache() + return store + + +@pytest.mark.asyncio +async def test_run_email_prefers_github_mapping(fake_store: _FakeStore) -> None: + await um.upsert_mapping( + github_login="johannes117", + work_email="johannes@langchain.dev", + source="admin", + ) + # OAuth profile carries a personal account that isn't an org member. + profile = {"email": "johannesduplessis117@gmail.com"} + assert await thread_api._resolve_run_email("johannes117", profile) == "johannes@langchain.dev" + + +@pytest.mark.asyncio +async def test_run_email_falls_back_to_profile_when_unmapped(fake_store: _FakeStore) -> None: + profile = {"email": "someone@example.com"} + assert await thread_api._resolve_run_email("nomapping", profile) == "someone@example.com"