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.
This commit is contained in:
Johannes du Plessis 2026-06-01 15:47:53 -07:00 • committed by GitHub
parent c41185a3ca
commit 7a9785c9d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 70 additions and 2 deletions

View file

@ -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):

View file

@ -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"