diff --git a/src/chat_sdk/adapters/linear/adapter.py b/src/chat_sdk/adapters/linear/adapter.py index 1230a06..18f1a89 100644 --- a/src/chat_sdk/adapters/linear/adapter.py +++ b/src/chat_sdk/adapters/linear/adapter.py @@ -22,10 +22,16 @@ from chat_sdk.adapters.linear.cards import card_to_linear_markdown from chat_sdk.adapters.linear.format_converter import LinearFormatConverter from chat_sdk.adapters.linear.types import ( + AgentActivityWebhookPayload, + AgentSessionEventWebhookPayload, + AgentSessionUserChild, + AgentSessionWebhookPayload, CommentWebhookPayload, + LinearActorData, LinearAdapterBaseConfig, LinearAdapterConfig, LinearAdapterMode, + LinearAgentSessionCommentRawMessage, LinearAgentSessionThreadId, LinearCommentData, LinearCommentRawMessage, @@ -88,6 +94,12 @@ COMMENT_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+)$") ISSUE_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):s:([^:]+)$") +# Linear profile URL → display name. Faithful port of upstream +# ``PROFILE_URL_REGEX`` (utils.ts:34). Anchored at the start (``^``) and stops +# the captured slug at the first ``/``, ``?``, or ``#`` so query strings / +# fragments / trailing path segments are excluded. +PROFILE_URL_REGEX = re.compile(r"^https://linear\.app/\S+/profiles/([^/?#]+)") + # Linear GraphQL API endpoint LINEAR_API_URL = "https://api.linear.app/graphql" @@ -113,6 +125,22 @@ } +def get_user_name_from_profile_url(url: str) -> str: + """Extract a user display name from a Linear profile URL. + + Faithful port of upstream ``getUserNameFromProfileUrl`` (utils.ts:40). A bit + of a hack to avoid fetching the user just to get the display name: the slug + after ``/profiles/`` in a Linear profile URL is the user's name. Returns + ``""`` (NOT ``None``) when the URL does not match — upstream returns the + empty string so the author's ``userName`` falls back to "" rather than + propagating an undefined. + """ + match = PROFILE_URL_REGEX.match(url) + if not match: + return "" + return match.group(1) + + def assert_agent_session_thread( thread: LinearThreadId, ) -> LinearAgentSessionThreadId: @@ -566,10 +594,29 @@ async def handle_webhook( # Handle events based on type. The payload shape is determined by # `type` at runtime — cast to the matching TypedDict so each handler # sees the right variant. + # + # Mode-gating (faithful port of index.ts:1144-1165): + # - "Comment" events are only handled in mode="comments" + # (``this.mode !== "comments" || action !== "create"`` → return). + # - "AgentSessionEvent" events are only handled in + # mode="agent-sessions"; in any other mode we warn and ignore. + # The gates are mutually exclusive, so a comment event in + # agent-sessions mode (and vice-versa) is dropped — even when the body + # @-mentions the bot's userName. payload_type = payload.get("type") if payload_type == "Comment": - if payload.get("action") == "create": + # Combined guard mirrors upstream's single `if` (mode + action). An + # empty-string / wrong action and a non-"comments" mode both fall + # through to the no-op without dispatching. + if self._mode == "comments" and payload.get("action") == "create": self._handle_comment_created(cast("CommentWebhookPayload", payload), options) + elif payload_type == "AgentSessionEvent": + if self._mode != "agent-sessions": + self._logger.warn( + "Received AgentSessionEvent webhook but adapter is not in agent-sessions mode, ignoring" + ) + else: + self._handle_agent_session_event(cast("AgentSessionEventWebhookPayload", payload), options) elif payload_type == "Reaction": self._handle_reaction(cast("ReactionWebhookPayload", payload)) @@ -638,6 +685,286 @@ def _handle_comment_created( self._chat.process_message(self, thread_id, message, options) + def _parse_agent_session_message( + self, + raw: LinearAgentSessionCommentRawMessage, + ) -> Message: + """Build a ``Message`` from an agent-session raw message. + + Faithful port of upstream ``parseMessage`` (index.ts:2026) for the + ``agent_session_comment`` branch. The existing :meth:`parse_message` + predates the upstream rewrite and does not reproduce the threadId + encode / ``is_mention`` / structured-author behavior, so the + agent-session path renders the ``Message`` here directly: + + - ``is_mention=True`` — agent-session comments directly target the bot, + so upstream always treats them as mentions. + - ``thread_id`` is re-encoded from the raw comment so the session + segment (``:s:{agentSessionId}``) is present on the routed thread. + - ``author`` is read from the structured ``comment.user`` written by + :meth:`_parse_message_from_agent_session_event` (display name, full + name, ``is_bot`` from ``type == "bot"``, ``is_me`` from bot-user-id). + """ + comment = raw["comment"] + text = cast("str", comment.get("body", "")) + user: LinearActorData = cast("LinearActorData", comment.get("user", {})) + + thread_id = self.encode_thread_id( + LinearThreadId( + issue_id=cast("str", comment.get("issueId", "")), + comment_id=cast("str | None", comment.get("id")), + agent_session_id=raw["agentSessionId"], + ) + ) + + # createdAt / updatedAt are ISO strings (the "created" branch reads + # `payload.createdAt` as a raw string — no Date cast). `edited` mirrors + # upstream's `createdAt !== updatedAt`. + created_at = cast("str", comment.get("createdAt", "")) + updated_at = cast("str", comment.get("updatedAt", "")) + + author = Author( + user_id=cast("str", user.get("id", "")), + user_name=cast("str", user.get("displayName", "")), + full_name=cast("str", user.get("fullName", "")), + is_bot=user.get("type") == "bot", + is_me=user.get("id") == self._bot_user_id, + ) + + return Message( + id=cast("str", comment.get("id", "")), + thread_id=thread_id, + is_mention=True, + text=text, + formatted=self._format_converter.to_ast(text), + author=author, + metadata=MessageMetadata( + date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc), + edited=created_at != updated_at, + edited_at=_parse_iso(updated_at) if (created_at != updated_at and updated_at) else None, + ), + attachments=[], + raw=cast("LinearRawMessage", raw), + ) + + def _parse_message_from_agent_session_event( + self, + payload: AgentSessionEventWebhookPayload, + ) -> Message | None: + """Parse an agent-session webhook event into a chat message, if applicable. + + Faithful port of upstream ``parseMessageFromAgentSessionEvent`` + (index.ts:955). Returns ``None`` (and logs a warning) when the event + cannot be parsed. Handles two actions: + + - ``"prompted"`` — a user posting a follow-up in an existing session. + - ``"created"`` — a user @-mentioning the bot, creating a new session. + + Any other action logs an "Unsupported agent session event action" + warning and returns ``None``. + """ + agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {})) + + # `issueId ?? issue?.id` — nullish (NOT truthy) fallback. Only fall back + # to the nested issue.id when issueId is *absent*; an empty string would + # be a real (if unusual) value, but we mirror upstream's `!issueId` + # falsy guard below, which still bails on empty. + issue_id = agent_session.get("issueId") + if issue_id is None: + issue = agent_session.get("issue") + issue_id = issue.get("id") if issue is not None else None + if not issue_id: + return None + + action = payload.get("action") + + # + # Follow-up message posted in an existing agent-session thread. + # + if action == "prompted": + agent_activity = cast("AgentActivityWebhookPayload | None", payload.get("agentActivity")) + if not agent_activity: + self._logger.warn( + "Missing agent activity for prompted action", + {"agentSessionId": agent_session.get("id")}, + ) + return None + + source_comment_id = agent_activity.get("sourceCommentId") + if not source_comment_id: + self._logger.warn( + "Missing source comment ID for agent activity", + { + "agentSessionId": agent_session.get("id"), + "agentActivityId": agent_activity.get("id"), + }, + ) + return None + + content = agent_activity.get("content", {}) + activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {})) + # `agentActivity.user.avatarUrl ?? undefined` — nullish. + avatar_url = activity_user.get("avatarUrl") + # `parentId: payload.agentSession.comment?.id` — optional chain. + # Short-circuit only on a missing/None comment (NOT a falsy empty + # dict), mirroring `?.`; then read id (absent → None). + prompted_session_comment = agent_session.get("comment") + parent_id = prompted_session_comment.get("id") if prompted_session_comment is not None else None + comment_data: LinearCommentData = { + "id": cast("str", source_comment_id), + "body": cast("str", content.get("body", "")), + "issueId": cast("str", issue_id), + "user": { + "type": "user", + "id": cast("str", activity_user.get("id", "")), + "displayName": get_user_name_from_profile_url(cast("str", activity_user.get("url", ""))), + "fullName": cast("str", activity_user.get("name", "")), + "email": cast("str", activity_user.get("email")), + **({"avatarUrl": cast("str", avatar_url)} if avatar_url is not None else {}), + }, + "parentId": cast("str", parent_id), + "createdAt": cast("str", agent_activity.get("createdAt", "")), + "updatedAt": cast("str", agent_activity.get("createdAt", "")), + } + # `payload.agentSession.url ?? undefined` — nullish. + session_url = agent_session.get("url") + if session_url is not None: + comment_data["url"] = cast("str", session_url) + + # `payload.promptContext ?? undefined` — nullish. + prompt_context = payload.get("promptContext") + raw: LinearAgentSessionCommentRawMessage = { + "kind": "agent_session_comment", + "organizationId": cast("str", payload.get("organizationId", "")), + "comment": comment_data, + "agentSessionId": cast("str", agent_session.get("id", "")), + } + if prompt_context is not None: + raw["agentSessionPromptContext"] = cast("str", prompt_context) + return self._parse_agent_session_message(raw) + + # + # New session: a user mentions the bot in an issue, opening a session + # and posting the first message. + # + if action == "created": + # App-ownership guard. `agentSession.appUserId !== this.botUserId`. + # We deliberately compare on the raw (possibly-None) values so a + # mismatch with a foreign bot's appUserId is rejected. A None + # botUserId only "matches" when appUserId is *also* None — that + # cannot happen for a real created event (appUserId is always set), + # so we never falsely accept a foreign session. + app_user_id = agent_session.get("appUserId") + if app_user_id != self._bot_user_id: + self._logger.warn( + "Ignoring agent session event from another bot", + { + "agentSessionId": agent_session.get("id"), + "appUserId": app_user_id, + }, + ) + return None + + session_comment = agent_session.get("comment") + if not session_comment: + self._logger.warn( + "Missing comment for agent session", + {"agentSessionId": agent_session.get("id")}, + ) + return None + + creator = agent_session.get("creator") + user: LinearActorData + if creator: + # `agentSession.creator.avatarUrl ?? undefined` — nullish. + creator_avatar = creator.get("avatarUrl") + user = { + "type": "user", + "id": cast("str", creator.get("id", "")), + "displayName": get_user_name_from_profile_url(cast("str", creator.get("url", ""))), + "fullName": cast("str", creator.get("name", "")), + "email": cast("str", creator.get("email")), + **({"avatarUrl": cast("str", creator_avatar)} if creator_avatar is not None else {}), + } + else: + # No creator → fall back to the bot author (upstream uses + # `this.botUserId` / `this.userName`). ``Author.user_id`` is a + # non-Optional ``str``, so coerce a None bot-user-id (not yet + # resolved by ``initialize``) to "" via ``is not None`` rather + # than truthiness (CLAUDE.md hazard). + user = { + "type": "bot", + "id": self._bot_user_id if self._bot_user_id is not None else "", + "displayName": self._user_name, + "fullName": self._user_name, + } + + comment_data = { + "id": cast("str", session_comment.get("id", "")), + "body": cast("str", session_comment.get("body", "")), + "issueId": cast("str", issue_id), + "user": user, + # The `created` branch reads `payload.createdAt` as a raw STRING + # (no Date cast — upstream's `@ts-expect-error` notes the SDK + # types are wrong about Date coercion for webhook payloads). + "createdAt": cast("str", payload.get("createdAt", "")), + "updatedAt": cast("str", payload.get("createdAt", "")), + } + # `payload.agentSession.url ?? undefined` — nullish. + session_url = agent_session.get("url") + if session_url is not None: + comment_data["url"] = cast("str", session_url) + + # `payload.promptContext ?? undefined` — nullish. + prompt_context = payload.get("promptContext") + raw = { + "kind": "agent_session_comment", + "organizationId": cast("str", payload.get("organizationId", "")), + "comment": comment_data, + "agentSessionId": cast("str", agent_session.get("id", "")), + } + if prompt_context is not None: + raw["agentSessionPromptContext"] = cast("str", prompt_context) + return self._parse_agent_session_message(raw) + + self._logger.warn( + "Unsupported agent session event action", + { + "action": action, + "agentSessionId": agent_session.get("id"), + "issueId": issue_id, + }, + ) + return None + + def _handle_agent_session_event( + self, + payload: AgentSessionEventWebhookPayload, + options: WebhookOptions | None = None, + ) -> None: + """Handle an agent-session webhook event. + + Faithful port of upstream ``handleAgentSessionEvent`` (index.ts:1269). + Builds a message via :meth:`_parse_message_from_agent_session_event` + and routes it to ``chat.process_message``. There is NO automatic + acknowledgement — the bot does not auto-respond on receipt (no + agentActivityCreate / typing / stream side-effect here; those land in + L4). When the event cannot be parsed, logs a warning and returns. + """ + if not self._chat: + self._logger.warn("Chat instance not initialized, ignoring agent session event") + return + + message = self._parse_message_from_agent_session_event(payload) + if not message: + self._logger.warn( + "Unable to build message for Linear agent session event", + {"agentSessionId": payload.get("agentSession", {}).get("id")}, + ) + return + + self._chat.process_message(self, message.thread_id, message, options) + def _handle_reaction(self, payload: ReactionWebhookPayload) -> None: """Handle reaction events (logging only).""" if not self._chat: diff --git a/src/chat_sdk/adapters/linear/types.py b/src/chat_sdk/adapters/linear/types.py index 1f4c76c..105a4f8 100644 --- a/src/chat_sdk/adapters/linear/types.py +++ b/src/chat_sdk/adapters/linear/types.py @@ -186,6 +186,26 @@ class LinearWebhookActor(TypedDict, total=False): url: str +class LinearActorData(TypedDict, total=False): + """Normalized author of a comment stored on a ``LinearCommentData``. + + Faithful port of upstream ``LinearActorData`` (types.ts:214). Distinct from + the raw webhook ``LinearWebhookActor``: this is the *normalized* shape that + ``_parse_message_from_agent_session_event`` writes onto the raw message's + ``comment.user`` so the parsed ``Message.author`` reflects the real + poster (display name, full name, bot/user discriminator). ``email`` / + ``avatarUrl`` are optional; ``type`` is ``"user" | "bot"`` (NOT the webhook + actor's ``"application" | "integration"``). + """ + + avatarUrl: str + displayName: str + email: str + fullName: str + id: str + type: str # "user" | "bot" + + class LinearCommentData(TypedDict, total=False): """Comment data from a webhook payload. @@ -207,8 +227,13 @@ class LinearCommentData(TypedDict, total=False): updatedAt: str # Direct URL to the comment url: str - # User UUID who wrote the comment + # User UUID who wrote the comment (raw webhook / flat-comment shape). userId: str + # Normalized author. Upstream's ``LinearCommentData.user`` is a required + # ``LinearActorData`` (types.ts:247); kept optional here (``total=False``) + # so the existing flat-comment webhook path — which only carries ``userId`` + # — is unaffected. ``_parse_message_from_agent_session_event`` populates it. + user: LinearActorData class CommentWebhookPayload(TypedDict, total=False): diff --git a/tests/test_linear_webhook.py b/tests/test_linear_webhook.py new file mode 100644 index 0000000..4b97bae --- /dev/null +++ b/tests/test_linear_webhook.py @@ -0,0 +1,716 @@ +"""Tests for the Linear agent-session webhook parse + routing (chat@4.31/#151 — L3). + +Ported from packages/adapter-linear/src/index.test.ts (the +``handleWebhook - agent session events`` describe block, index.test.ts:1243-1471) +plus the ``getUserNameFromProfileUrl`` util (utils.ts:40). + +Covers: +- mode-gating (AgentSessionEvent only in agent-sessions mode; Comment only in + comments mode), including the inversion both ways; +- ``_parse_message_from_agent_session_event`` for the ``created`` and + ``prompted`` actions, the null-return + warn paths, and the bot-author + fallback; +- app-ownership guard (own bot vs. foreign bot); +- ``createdAt`` carried as a raw string; +- no-auto-acknowledge (process_message routed, but no outbound API call); +- ``get_user_name_from_profile_url`` regex. + +Each test is written to FAIL under a plausible mutation — in particular a +``??`` → ``or`` truthiness swap or a mode-gate inversion (see the +``test_*_mutation_*`` docstrings). +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.adapters.linear.adapter import ( + LinearAdapter, + get_user_name_from_profile_url, +) +from chat_sdk.adapters.linear.types import LinearAdapterAPIKeyConfig + +WEBHOOK_SECRET = "test-webhook-secret" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_logger() -> MagicMock: + return MagicMock( + debug=MagicMock(), + info=MagicMock(), + warn=MagicMock(), + error=MagicMock(), + ) + + +def _make_chat() -> MagicMock: + """A mock ChatInstance. + + ``process_message`` is a sync MagicMock (the adapter calls it + synchronously, matching upstream ``chat.processMessage(...)``); async + surface methods are AsyncMock so an accidental missing-await surfaces. + """ + chat = MagicMock() + chat.process_message = MagicMock() + chat.handle_incoming_message = AsyncMock() + chat.get_state = MagicMock(return_value=None) + chat.get_user_name = MagicMock(return_value="test-bot") + return chat + + +def _make_webhook_adapter(mode: str, logger: MagicMock | None = None) -> LinearAdapter: + if logger is None: + logger = _make_logger() + config = LinearAdapterAPIKeyConfig( + api_key="test-api-key", + webhook_secret=WEBHOOK_SECRET, + user_name="test-bot", + mode=mode, # type: ignore[arg-type] + logger=logger, + ) + adapter = LinearAdapter(config) + # Single-tenant bot-user-id is set by ``initialize`` (a viewer query) in + # production; pre-set it here so the app-ownership guard has something to + # compare against (mirrors upstream's ``setBotUserId`` test seam). + adapter._bot_user_id = "bot-user-id" + return adapter + + +def _sign_payload(body: str, secret: str = WEBHOOK_SECRET) -> str: + return hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest() + + +class _FakeRequest: + def __init__(self, body: str, headers: dict[str, str] | None = None): + self._body = body + self.headers = headers or {} + + async def text(self) -> str: + return self._body + + +def _build_webhook_request(body: str, signature: str | None = None) -> _FakeRequest: + headers: dict[str, str] = {"content-type": "application/json"} + if signature is not None: + headers["linear-signature"] = signature + return _FakeRequest(body, headers) + + +def _signed_request(payload: dict[str, Any]) -> _FakeRequest: + body = json.dumps(payload) + return _build_webhook_request(body, _sign_payload(body)) + + +# Sentinel distinguishing "key absent" from "value is None" for the creator / +# sourceCommentId overrides (mirrors upstream's ``"creator" in overrides``). +_UNSET = object() + + +def _create_agent_session_payload( + *, + action: str = "created", + activity_body: str = "Hello from app actor", + app_user_id: str = "bot-user-id", + comment_id: str = "comment-root", + creator: Any = _UNSET, + issue_id: str = "issue-123", + prompt_context: str = "Issue TEST-1\n\n@get-bot Hello there", + session_id: str = "agent-session-1", + source_comment_body: str = "@test-bot Hello there", + source_comment_id: Any = _UNSET, + session_url: Any = None, +) -> dict[str, Any]: + """Faithful port of ``createAgentSessionPayload`` (index.test.ts:446).""" + resolved_source_comment_id = "comment-source" if source_comment_id is _UNSET else source_comment_id + resolved_creator: Any + if creator is _UNSET: + resolved_creator = { + "id": "user-456", + "name": "Test User", + "email": None, + "avatarUrl": None, + "url": "https://linear.app/test/profiles/test-user", + } + else: + resolved_creator = creator + + return { + "type": "AgentSessionEvent", + "action": action, + "createdAt": "2025-06-01T12:00:00.000Z", + "appUserId": app_user_id, + "oauthClientId": "oauth-client-123", + "organizationId": "org-123", + "webhookId": "webhook-agent-1", + "webhookTimestamp": int(time.time() * 1000), + "promptContext": prompt_context, + "agentSession": { + "id": session_id, + "appUserId": app_user_id, + "issueId": issue_id, + "commentId": comment_id, + "sourceCommentId": resolved_source_comment_id, + "comment": { + "id": comment_id, + "body": source_comment_body, + "userId": resolved_creator["id"] if resolved_creator else None, + }, + "creator": resolved_creator, + "status": "active", + "summary": "Help with the issue", + "url": session_url, + }, + "agentActivity": { + "id": "agent-activity-1", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "content": { + "type": "prompt", + "body": activity_body, + }, + }, + "actor": { + "id": "user-456", + "name": "Test User", + "type": "user", + }, + } + + +def _create_comment_payload( + *, + body: str = "@test-bot hello", + comment_id: str = "comment-abc", + issue_id: str = "issue-123", + user_id: str = "user-456", +) -> dict[str, Any]: + return { + "type": "Comment", + "action": "create", + "createdAt": "2025-06-01T12:00:00.000Z", + "organizationId": "org-123", + "url": "https://linear.app/test/issue/TEST-1#comment-abc", + "webhookId": "webhook-1", + "webhookTimestamp": int(time.time() * 1000), + "data": { + "id": comment_id, + "body": body, + "issueId": issue_id, + "userId": user_id, + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "parentId": None, + }, + "actor": { + "id": user_id, + "name": "Test User", + "type": "user", + }, + } + + +# --------------------------------------------------------------------------- +# get_user_name_from_profile_url +# --------------------------------------------------------------------------- + + +class TestGetUserNameFromProfileUrl: + def test_extracts_slug_after_profiles(self): + assert get_user_name_from_profile_url("https://linear.app/test/profiles/test-user") == "test-user" + + def test_stops_at_query_string(self): + # `[^/?#]+` excludes the query — a `??`/regex slip that captured the + # whole tail would return "john?tab=activity". + assert get_user_name_from_profile_url("https://linear.app/acme/profiles/john?tab=activity") == "john" + + def test_stops_at_fragment(self): + assert get_user_name_from_profile_url("https://linear.app/acme/profiles/jane#bio") == "jane" + + def test_stops_at_trailing_path_segment(self): + assert get_user_name_from_profile_url("https://linear.app/acme/profiles/sam/activity") == "sam" + + def test_returns_empty_string_on_non_match(self): + # Returns "" (NOT None) — a mutation to `return None` would break the + # non-Optional ``str`` contract callers rely on. + assert get_user_name_from_profile_url("https://example.com/profiles/sam") == "" + + def test_returns_empty_string_when_no_profiles_segment(self): + assert get_user_name_from_profile_url("https://linear.app/acme/issue/TEST-1") == "" + + def test_requires_https_anchor(self): + # Anchored at `^https://` — an http URL must not match. + assert get_user_name_from_profile_url("http://linear.app/acme/profiles/sam") == "" + + +# --------------------------------------------------------------------------- +# Mode-gating +# --------------------------------------------------------------------------- + + +class TestModeGating: + @pytest.mark.asyncio + async def test_ignores_agent_session_events_in_comment_mode(self): + logger = _make_logger() + adapter = _make_webhook_adapter("comments", logger) + chat = _make_chat() + adapter._chat = chat + + response = await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + assert response["status"] == 200 + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Received AgentSessionEvent webhook but adapter is not in agent-sessions mode, ignoring" + ) + + @pytest.mark.asyncio + async def test_ignores_comment_webhooks_in_agent_session_mode(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + response = await adapter.handle_webhook( + _signed_request(_create_comment_payload(body="@test-bot hello", comment_id="comment-source")) + ) + + assert response["status"] == 200 + chat.process_message.assert_not_called() + + @pytest.mark.asyncio + async def test_ignores_comment_webhooks_in_agent_session_mode_even_if_mentions_username(self): + # Mirrors index.test.ts:1443 — a @-mention of the bot's userName must + # still be dropped while in agent-sessions mode. + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + chat.get_user_name = MagicMock(return_value="getsquad-dev-samy") + adapter._chat = chat + + response = await adapter.handle_webhook( + _signed_request(_create_comment_payload(body="@getsquad-dev-samy hello", comment_id="comment-abc")) + ) + + assert response["status"] == 200 + chat.process_message.assert_not_called() + + @pytest.mark.asyncio + async def test_comment_still_dispatched_in_comment_mode(self): + # Anchors the gate: comments DO flow through when mode == "comments". + # If the gate were inverted, this would fail (no dispatch). + logger = _make_logger() + adapter = _make_webhook_adapter("comments", logger) + chat = _make_chat() + adapter._chat = chat + + response = await adapter.handle_webhook(_signed_request(_create_comment_payload())) + + assert response["status"] == 200 + chat.process_message.assert_called_once() + + @pytest.mark.asyncio + async def test_mutation_mode_gate_inversion_would_be_caught(self): + """A single test combining both gate directions. + + If the AgentSessionEvent gate were inverted (handled in "comments" + mode), the comment-mode adapter would dispatch the agent event — the + ``assert_not_called`` below catches that. Paired with + ``test_dispatches_created_events_*`` (agent-sessions mode DOES + dispatch), the inversion is fully pinned in both directions. + """ + logger = _make_logger() + adapter = _make_webhook_adapter("comments", logger) + chat = _make_chat() + adapter._chat = chat + + await adapter.handle_webhook(_signed_request(_create_agent_session_payload(action="created"))) + + chat.process_message.assert_not_called() + + +# --------------------------------------------------------------------------- +# created action +# --------------------------------------------------------------------------- + + +class TestCreatedAction: + @pytest.mark.asyncio + async def test_dispatches_created_events_when_owned_by_this_bot(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + response = await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + assert response["status"] == 200 + chat.process_message.assert_called_once() + message = chat.process_message.call_args[0][2] + assert message.thread_id == "linear:issue-123:c:comment-root:s:agent-session-1" + assert message.author.user_id == "user-456" + # userName comes from the creator's profile URL (.../profiles/test-user). + assert message.author.user_name == "test-user" + assert message.author.is_bot is False + assert message.author.is_me is False + + @pytest.mark.asyncio + async def test_created_event_is_treated_as_a_mention(self): + # Agent-session comments directly target the bot → isMention True. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + message = chat.process_message.call_args[0][2] + assert message.is_mention is True + + @pytest.mark.asyncio + async def test_routed_thread_id_matches_message_thread_id(self): + # ``_handle_agent_session_event`` routes on ``message.thread_id`` (the + # 2nd positional arg), which must equal the encoded session thread. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + routed_thread_id = chat.process_message.call_args[0][1] + message = chat.process_message.call_args[0][2] + assert routed_thread_id == "linear:issue-123:c:comment-root:s:agent-session-1" + assert routed_thread_id == message.thread_id + + @pytest.mark.asyncio + async def test_falls_back_to_bot_author_when_created_session_has_no_creator(self): + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + await adapter.handle_webhook(_signed_request(_create_agent_session_payload(creator=None))) + + chat.process_message.assert_called_once() + message = chat.process_message.call_args[0][2] + assert message.author.user_id == "bot-user-id" + assert message.author.user_name == "test-bot" + assert message.author.is_bot is True + assert message.author.is_me is True + + @pytest.mark.asyncio + async def test_ignores_created_events_that_belong_to_another_bot(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + await adapter.handle_webhook(_signed_request(_create_agent_session_payload(app_user_id="other-bot-id"))) + + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Ignoring agent session event from another bot", + {"agentSessionId": "agent-session-1", "appUserId": "other-bot-id"}, + ) + + @pytest.mark.asyncio + async def test_created_event_carries_created_at_as_string_in_metadata(self): + # The ``created`` branch reads ``payload.createdAt`` as a raw STRING + # (no Date cast upstream). The parsed metadata.date_sent reflects it. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + payload["createdAt"] = "2025-03-15T10:30:00.000Z" + await adapter.handle_webhook(_signed_request(payload)) + + message = chat.process_message.call_args[0][2] + assert message.metadata.date_sent.year == 2025 + assert message.metadata.date_sent.month == 3 + assert message.metadata.date_sent.day == 15 + # createdAt == updatedAt (both from payload.createdAt) → not edited. + assert message.metadata.edited is False + + @pytest.mark.asyncio + async def test_no_automatic_acknowledgement_for_created_events(self): + # No-auto-ack: the adapter routes the message but performs no outbound + # API call (no agentActivityCreate; that lands in L4). Stub the GraphQL + # transport and assert it is never touched. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + graphql = AsyncMock() + adapter._graphql_query = graphql # type: ignore[method-assign] + + response = await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + assert response["status"] == 200 + chat.process_message.assert_called_once() + graphql.assert_not_called() + + +# --------------------------------------------------------------------------- +# prompted action + null-return / warn paths +# --------------------------------------------------------------------------- + + +class TestPromptedAction: + @pytest.mark.asyncio + async def test_prompted_without_activity_source_comment_id_returns_null(self): + # The default agent-activity payload has NO ``sourceCommentId`` on the + # activity (only the session carries one). The prompted branch warns + # and returns null → no dispatch. Mirrors index.test.ts:1286. + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + response = await adapter.handle_webhook( + _signed_request(_create_agent_session_payload(action="prompted", activity_body="Can you elaborate?")) + ) + + assert response["status"] == 200 + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Missing source comment ID for agent activity", + {"agentSessionId": "agent-session-1", "agentActivityId": "agent-activity-1"}, + ) + + @pytest.mark.asyncio + async def test_prompted_with_source_comment_id_dispatches(self): + # When the activity DOES carry a sourceCommentId, the prompted branch + # builds a message (no mention flag inversion — it's still a mention). + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload(action="prompted", activity_body="Can you elaborate?") + payload["agentActivity"]["sourceCommentId"] = "activity-comment-99" + payload["agentActivity"]["user"] = { + "id": "user-789", + "name": "Prompter", + "email": None, + "avatarUrl": None, + "url": "https://linear.app/test/profiles/prompter", + } + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_called_once() + message = chat.process_message.call_args[0][2] + assert message.id == "activity-comment-99" + assert message.text == "Can you elaborate?" + assert message.author.user_id == "user-789" + assert message.author.user_name == "prompter" + assert message.is_mention is True + # parse_message encodes the thread comment segment from the raw + # comment's id, which for "prompted" is the activity's sourceCommentId. + assert message.thread_id == "linear:issue-123:c:activity-comment-99:s:agent-session-1" + + @pytest.mark.asyncio + async def test_prompted_without_agent_activity_returns_null(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload(action="prompted") + payload["agentActivity"] = None + + response = await adapter.handle_webhook(_signed_request(payload)) + + assert response["status"] == 200 + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Missing agent activity for prompted action", + {"agentSessionId": "agent-session-1"}, + ) + + +# --------------------------------------------------------------------------- +# null-return paths shared across actions +# --------------------------------------------------------------------------- + + +class TestNullReturnPaths: + @pytest.mark.asyncio + async def test_missing_issue_id_returns_null(self): + # `issueId ?? issue?.id` — when both are absent, parse returns null and + # _handle_agent_session_event warns "Unable to build message". + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + del payload["agentSession"]["issueId"] + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Unable to build message for Linear agent session event", + {"agentSessionId": "agent-session-1"}, + ) + + @pytest.mark.asyncio + async def test_falls_back_to_nested_issue_id(self): + # `issueId ?? issue?.id` (nullish): absent top-level issueId → use the + # nested issue.id. A `or` swap would behave the same here, so this is + # paired with ``test_empty_issue_id_does_not_use_nested`` to pin the + # nullish (vs. truthy) semantics. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + del payload["agentSession"]["issueId"] + payload["agentSession"]["issue"] = {"id": "issue-from-nested"} + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_called_once() + message = chat.process_message.call_args[0][2] + assert message.thread_id == "linear:issue-from-nested:c:comment-root:s:agent-session-1" + + @pytest.mark.asyncio + async def test_missing_comment_for_created_session_returns_null(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + del payload["agentSession"]["comment"] + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Missing comment for agent session", + {"agentSessionId": "agent-session-1"}, + ) + + @pytest.mark.asyncio + async def test_unsupported_action_returns_null_and_warns(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload(action="completed") + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Unsupported agent session event action", + {"action": "completed", "agentSessionId": "agent-session-1", "issueId": "issue-123"}, + ) + + @pytest.mark.asyncio + async def test_chat_not_initialized_warns_and_returns(self): + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + adapter._chat = None + + response = await adapter.handle_webhook(_signed_request(_create_agent_session_payload())) + + assert response["status"] == 200 + logger.warn.assert_any_call("Chat instance not initialized, ignoring agent session event") + + +# --------------------------------------------------------------------------- +# nullish-vs-truthy regression guards (the ??→or mutation crux) +# --------------------------------------------------------------------------- + + +class TestNullishSemantics: + @pytest.mark.asyncio + async def test_empty_issue_id_does_not_use_nested(self): + """`issueId ?? issue?.id` is nullish, NOT truthy. + + With an EMPTY-STRING top-level issueId and a non-empty nested issue.id, + the nullish operator keeps the empty string (a real value) → the + `!issueId` guard then bails (returns null, no dispatch). A `??` → `or` + mutation would instead fall through to the nested id and DISPATCH — + this test would then see a process_message call and fail. + """ + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + payload["agentSession"]["issueId"] = "" + payload["agentSession"]["issue"] = {"id": "issue-from-nested"} + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_not_called() + + @pytest.mark.asyncio + async def test_session_url_falsy_empty_string_is_preserved(self): + """`payload.agentSession.url ?? undefined` is nullish. + + An empty-string session url is a real value: it must be carried onto + the raw comment's ``url`` rather than dropped. A `url or None` + truthiness swap would drop the empty string. We assert the raw carries + ``url == ""``. + """ + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload(session_url="") + await adapter.handle_webhook(_signed_request(payload)) + + message = chat.process_message.call_args[0][2] + assert message.raw["comment"]["url"] == "" + + @pytest.mark.asyncio + async def test_session_url_none_is_omitted(self): + # `url ?? undefined`: a None url is omitted (key absent), not stored. + adapter = _make_webhook_adapter("agent-sessions") + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload(session_url=None) + await adapter.handle_webhook(_signed_request(payload)) + + message = chat.process_message.call_args[0][2] + assert "url" not in message.raw["comment"] + + @pytest.mark.asyncio + async def test_app_ownership_none_app_user_id_is_rejected_against_real_bot(self): + """App-ownership guard: a None appUserId must NOT match a real botUserId. + + `agentSession.appUserId !== this.botUserId`. With bot_user_id set and a + missing/None appUserId, the comparison is a mismatch → ignored. (Guards + the inverse of the None-botUserId hazard.) + """ + logger = _make_logger() + adapter = _make_webhook_adapter("agent-sessions", logger) + chat = _make_chat() + adapter._chat = chat + + payload = _create_agent_session_payload() + payload["agentSession"]["appUserId"] = None + payload["appUserId"] = None + + await adapter.handle_webhook(_signed_request(payload)) + + chat.process_message.assert_not_called() + logger.warn.assert_any_call( + "Ignoring agent session event from another bot", + {"agentSessionId": "agent-session-1", "appUserId": None}, + )