Skip to content

Commit baed3f6

Browse files
feat(linear): agent-session webhook parse + routing (chat@4.31/#151 — L3/5) (#171)
Port the agent-session inbound webhook path from upstream adapter-linear/src/index.ts (chat@4.31): - handle_webhook: add an AgentSessionEvent branch and mode-gate both branches — Comment events only in mode="comments", AgentSessionEvent only in mode="agent-sessions" (warn + ignore otherwise). The gates are mutually exclusive, so a comment in agent-sessions mode (even one that @-mentions the bot's userName) and an agent event in comments mode are both dropped, matching index.ts:1144-1165. - _parse_message_from_agent_session_event: handle the "created" and "prompted" actions, with the null-return + warn branches (missing agent activity, missing source comment id, missing comment, another bot's session, unsupported action). Faithful port of index.ts:955. - _handle_agent_session_event: route the parsed message to chat.process_message with no automatic acknowledgement (no outbound API call on receipt; the agentActivityCreate/typing/stream emit lands in L4). Faithful port of index.ts:1269. - get_user_name_from_profile_url: extract the slug after /profiles/ from a Linear profile URL, returning "" on non-match (utils.ts:40). - LinearActorData type + an optional normalized comment.user, so the agent-session author (display name / full name / bot vs user) is carried without disturbing the flat comment-webhook path. Nullish-vs-truthy fidelity (the #1 port risk): every upstream ?? / ?. site is reproduced with is-not-None semantics, not truthiness — issueId ?? issue?.id, agentSession.url ?? undefined, promptContext ?? undefined, creator/activity avatarUrl ?? undefined, agentSession.comment?.id. The created branch reads payload.createdAt as a raw string (no Date cast). The app-ownership guard compares appUserId != botUserId on the raw values so a foreign session is rejected and a None botUserId never falsely matches. Tests (tests/test_linear_webhook.py): created/prompted dispatch, both mode-gate directions, the null-return + warn paths, app-ownership, bot-author fallback, createdAt-string, no-auto-ack, and the profile-url regex. Each fails under a plausible mutation (??->or swap, mode-gate inversion, app-ownership ==/!=, is_mention flip, url nullish->truthy). L4 (emit) and L5 (fetch) consume the mode gate, the decoded session thread, and the agent_session_comment raw message produced here.
1 parent 4d10adb commit baed3f6

3 files changed

Lines changed: 1070 additions & 2 deletions

File tree

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 328 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@
2222
from chat_sdk.adapters.linear.cards import card_to_linear_markdown
2323
from chat_sdk.adapters.linear.format_converter import LinearFormatConverter
2424
from chat_sdk.adapters.linear.types import (
25+
AgentActivityWebhookPayload,
26+
AgentSessionEventWebhookPayload,
27+
AgentSessionUserChild,
28+
AgentSessionWebhookPayload,
2529
CommentWebhookPayload,
30+
LinearActorData,
2631
LinearAdapterBaseConfig,
2732
LinearAdapterConfig,
2833
LinearAdapterMode,
34+
LinearAgentSessionCommentRawMessage,
2935
LinearAgentSessionThreadId,
3036
LinearCommentData,
3137
LinearCommentRawMessage,
@@ -88,6 +94,12 @@
8894
COMMENT_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+)$")
8995
ISSUE_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):s:([^:]+)$")
9096

97+
# Linear profile URL → display name. Faithful port of upstream
98+
# ``PROFILE_URL_REGEX`` (utils.ts:34). Anchored at the start (``^``) and stops
99+
# the captured slug at the first ``/``, ``?``, or ``#`` so query strings /
100+
# fragments / trailing path segments are excluded.
101+
PROFILE_URL_REGEX = re.compile(r"^https://linear\.app/\S+/profiles/([^/?#]+)")
102+
91103
# Linear GraphQL API endpoint
92104
LINEAR_API_URL = "https://api.linear.app/graphql"
93105

@@ -113,6 +125,22 @@
113125
}
114126

115127

128+
def get_user_name_from_profile_url(url: str) -> str:
129+
"""Extract a user display name from a Linear profile URL.
130+
131+
Faithful port of upstream ``getUserNameFromProfileUrl`` (utils.ts:40). A bit
132+
of a hack to avoid fetching the user just to get the display name: the slug
133+
after ``/profiles/`` in a Linear profile URL is the user's name. Returns
134+
``""`` (NOT ``None``) when the URL does not match — upstream returns the
135+
empty string so the author's ``userName`` falls back to "" rather than
136+
propagating an undefined.
137+
"""
138+
match = PROFILE_URL_REGEX.match(url)
139+
if not match:
140+
return ""
141+
return match.group(1)
142+
143+
116144
def assert_agent_session_thread(
117145
thread: LinearThreadId,
118146
) -> LinearAgentSessionThreadId:
@@ -566,10 +594,29 @@ async def handle_webhook(
566594
# Handle events based on type. The payload shape is determined by
567595
# `type` at runtime — cast to the matching TypedDict so each handler
568596
# sees the right variant.
597+
#
598+
# Mode-gating (faithful port of index.ts:1144-1165):
599+
# - "Comment" events are only handled in mode="comments"
600+
# (``this.mode !== "comments" || action !== "create"`` → return).
601+
# - "AgentSessionEvent" events are only handled in
602+
# mode="agent-sessions"; in any other mode we warn and ignore.
603+
# The gates are mutually exclusive, so a comment event in
604+
# agent-sessions mode (and vice-versa) is dropped — even when the body
605+
# @-mentions the bot's userName.
569606
payload_type = payload.get("type")
570607
if payload_type == "Comment":
571-
if payload.get("action") == "create":
608+
# Combined guard mirrors upstream's single `if` (mode + action). An
609+
# empty-string / wrong action and a non-"comments" mode both fall
610+
# through to the no-op without dispatching.
611+
if self._mode == "comments" and payload.get("action") == "create":
572612
self._handle_comment_created(cast("CommentWebhookPayload", payload), options)
613+
elif payload_type == "AgentSessionEvent":
614+
if self._mode != "agent-sessions":
615+
self._logger.warn(
616+
"Received AgentSessionEvent webhook but adapter is not in agent-sessions mode, ignoring"
617+
)
618+
else:
619+
self._handle_agent_session_event(cast("AgentSessionEventWebhookPayload", payload), options)
573620
elif payload_type == "Reaction":
574621
self._handle_reaction(cast("ReactionWebhookPayload", payload))
575622

@@ -638,6 +685,286 @@ def _handle_comment_created(
638685

639686
self._chat.process_message(self, thread_id, message, options)
640687

688+
def _parse_agent_session_message(
689+
self,
690+
raw: LinearAgentSessionCommentRawMessage,
691+
) -> Message:
692+
"""Build a ``Message`` from an agent-session raw message.
693+
694+
Faithful port of upstream ``parseMessage`` (index.ts:2026) for the
695+
``agent_session_comment`` branch. The existing :meth:`parse_message`
696+
predates the upstream rewrite and does not reproduce the threadId
697+
encode / ``is_mention`` / structured-author behavior, so the
698+
agent-session path renders the ``Message`` here directly:
699+
700+
- ``is_mention=True`` — agent-session comments directly target the bot,
701+
so upstream always treats them as mentions.
702+
- ``thread_id`` is re-encoded from the raw comment so the session
703+
segment (``:s:{agentSessionId}``) is present on the routed thread.
704+
- ``author`` is read from the structured ``comment.user`` written by
705+
:meth:`_parse_message_from_agent_session_event` (display name, full
706+
name, ``is_bot`` from ``type == "bot"``, ``is_me`` from bot-user-id).
707+
"""
708+
comment = raw["comment"]
709+
text = cast("str", comment.get("body", ""))
710+
user: LinearActorData = cast("LinearActorData", comment.get("user", {}))
711+
712+
thread_id = self.encode_thread_id(
713+
LinearThreadId(
714+
issue_id=cast("str", comment.get("issueId", "")),
715+
comment_id=cast("str | None", comment.get("id")),
716+
agent_session_id=raw["agentSessionId"],
717+
)
718+
)
719+
720+
# createdAt / updatedAt are ISO strings (the "created" branch reads
721+
# `payload.createdAt` as a raw string — no Date cast). `edited` mirrors
722+
# upstream's `createdAt !== updatedAt`.
723+
created_at = cast("str", comment.get("createdAt", ""))
724+
updated_at = cast("str", comment.get("updatedAt", ""))
725+
726+
author = Author(
727+
user_id=cast("str", user.get("id", "")),
728+
user_name=cast("str", user.get("displayName", "")),
729+
full_name=cast("str", user.get("fullName", "")),
730+
is_bot=user.get("type") == "bot",
731+
is_me=user.get("id") == self._bot_user_id,
732+
)
733+
734+
return Message(
735+
id=cast("str", comment.get("id", "")),
736+
thread_id=thread_id,
737+
is_mention=True,
738+
text=text,
739+
formatted=self._format_converter.to_ast(text),
740+
author=author,
741+
metadata=MessageMetadata(
742+
date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc),
743+
edited=created_at != updated_at,
744+
edited_at=_parse_iso(updated_at) if (created_at != updated_at and updated_at) else None,
745+
),
746+
attachments=[],
747+
raw=cast("LinearRawMessage", raw),
748+
)
749+
750+
def _parse_message_from_agent_session_event(
751+
self,
752+
payload: AgentSessionEventWebhookPayload,
753+
) -> Message | None:
754+
"""Parse an agent-session webhook event into a chat message, if applicable.
755+
756+
Faithful port of upstream ``parseMessageFromAgentSessionEvent``
757+
(index.ts:955). Returns ``None`` (and logs a warning) when the event
758+
cannot be parsed. Handles two actions:
759+
760+
- ``"prompted"`` — a user posting a follow-up in an existing session.
761+
- ``"created"`` — a user @-mentioning the bot, creating a new session.
762+
763+
Any other action logs an "Unsupported agent session event action"
764+
warning and returns ``None``.
765+
"""
766+
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {}))
767+
768+
# `issueId ?? issue?.id` — nullish (NOT truthy) fallback. Only fall back
769+
# to the nested issue.id when issueId is *absent*; an empty string would
770+
# be a real (if unusual) value, but we mirror upstream's `!issueId`
771+
# falsy guard below, which still bails on empty.
772+
issue_id = agent_session.get("issueId")
773+
if issue_id is None:
774+
issue = agent_session.get("issue")
775+
issue_id = issue.get("id") if issue is not None else None
776+
if not issue_id:
777+
return None
778+
779+
action = payload.get("action")
780+
781+
#
782+
# Follow-up message posted in an existing agent-session thread.
783+
#
784+
if action == "prompted":
785+
agent_activity = cast("AgentActivityWebhookPayload | None", payload.get("agentActivity"))
786+
if not agent_activity:
787+
self._logger.warn(
788+
"Missing agent activity for prompted action",
789+
{"agentSessionId": agent_session.get("id")},
790+
)
791+
return None
792+
793+
source_comment_id = agent_activity.get("sourceCommentId")
794+
if not source_comment_id:
795+
self._logger.warn(
796+
"Missing source comment ID for agent activity",
797+
{
798+
"agentSessionId": agent_session.get("id"),
799+
"agentActivityId": agent_activity.get("id"),
800+
},
801+
)
802+
return None
803+
804+
content = agent_activity.get("content", {})
805+
activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {}))
806+
# `agentActivity.user.avatarUrl ?? undefined` — nullish.
807+
avatar_url = activity_user.get("avatarUrl")
808+
# `parentId: payload.agentSession.comment?.id` — optional chain.
809+
# Short-circuit only on a missing/None comment (NOT a falsy empty
810+
# dict), mirroring `?.`; then read id (absent → None).
811+
prompted_session_comment = agent_session.get("comment")
812+
parent_id = prompted_session_comment.get("id") if prompted_session_comment is not None else None
813+
comment_data: LinearCommentData = {
814+
"id": cast("str", source_comment_id),
815+
"body": cast("str", content.get("body", "")),
816+
"issueId": cast("str", issue_id),
817+
"user": {
818+
"type": "user",
819+
"id": cast("str", activity_user.get("id", "")),
820+
"displayName": get_user_name_from_profile_url(cast("str", activity_user.get("url", ""))),
821+
"fullName": cast("str", activity_user.get("name", "")),
822+
"email": cast("str", activity_user.get("email")),
823+
**({"avatarUrl": cast("str", avatar_url)} if avatar_url is not None else {}),
824+
},
825+
"parentId": cast("str", parent_id),
826+
"createdAt": cast("str", agent_activity.get("createdAt", "")),
827+
"updatedAt": cast("str", agent_activity.get("createdAt", "")),
828+
}
829+
# `payload.agentSession.url ?? undefined` — nullish.
830+
session_url = agent_session.get("url")
831+
if session_url is not None:
832+
comment_data["url"] = cast("str", session_url)
833+
834+
# `payload.promptContext ?? undefined` — nullish.
835+
prompt_context = payload.get("promptContext")
836+
raw: LinearAgentSessionCommentRawMessage = {
837+
"kind": "agent_session_comment",
838+
"organizationId": cast("str", payload.get("organizationId", "")),
839+
"comment": comment_data,
840+
"agentSessionId": cast("str", agent_session.get("id", "")),
841+
}
842+
if prompt_context is not None:
843+
raw["agentSessionPromptContext"] = cast("str", prompt_context)
844+
return self._parse_agent_session_message(raw)
845+
846+
#
847+
# New session: a user mentions the bot in an issue, opening a session
848+
# and posting the first message.
849+
#
850+
if action == "created":
851+
# App-ownership guard. `agentSession.appUserId !== this.botUserId`.
852+
# We deliberately compare on the raw (possibly-None) values so a
853+
# mismatch with a foreign bot's appUserId is rejected. A None
854+
# botUserId only "matches" when appUserId is *also* None — that
855+
# cannot happen for a real created event (appUserId is always set),
856+
# so we never falsely accept a foreign session.
857+
app_user_id = agent_session.get("appUserId")
858+
if app_user_id != self._bot_user_id:
859+
self._logger.warn(
860+
"Ignoring agent session event from another bot",
861+
{
862+
"agentSessionId": agent_session.get("id"),
863+
"appUserId": app_user_id,
864+
},
865+
)
866+
return None
867+
868+
session_comment = agent_session.get("comment")
869+
if not session_comment:
870+
self._logger.warn(
871+
"Missing comment for agent session",
872+
{"agentSessionId": agent_session.get("id")},
873+
)
874+
return None
875+
876+
creator = agent_session.get("creator")
877+
user: LinearActorData
878+
if creator:
879+
# `agentSession.creator.avatarUrl ?? undefined` — nullish.
880+
creator_avatar = creator.get("avatarUrl")
881+
user = {
882+
"type": "user",
883+
"id": cast("str", creator.get("id", "")),
884+
"displayName": get_user_name_from_profile_url(cast("str", creator.get("url", ""))),
885+
"fullName": cast("str", creator.get("name", "")),
886+
"email": cast("str", creator.get("email")),
887+
**({"avatarUrl": cast("str", creator_avatar)} if creator_avatar is not None else {}),
888+
}
889+
else:
890+
# No creator → fall back to the bot author (upstream uses
891+
# `this.botUserId` / `this.userName`). ``Author.user_id`` is a
892+
# non-Optional ``str``, so coerce a None bot-user-id (not yet
893+
# resolved by ``initialize``) to "" via ``is not None`` rather
894+
# than truthiness (CLAUDE.md hazard).
895+
user = {
896+
"type": "bot",
897+
"id": self._bot_user_id if self._bot_user_id is not None else "",
898+
"displayName": self._user_name,
899+
"fullName": self._user_name,
900+
}
901+
902+
comment_data = {
903+
"id": cast("str", session_comment.get("id", "")),
904+
"body": cast("str", session_comment.get("body", "")),
905+
"issueId": cast("str", issue_id),
906+
"user": user,
907+
# The `created` branch reads `payload.createdAt` as a raw STRING
908+
# (no Date cast — upstream's `@ts-expect-error` notes the SDK
909+
# types are wrong about Date coercion for webhook payloads).
910+
"createdAt": cast("str", payload.get("createdAt", "")),
911+
"updatedAt": cast("str", payload.get("createdAt", "")),
912+
}
913+
# `payload.agentSession.url ?? undefined` — nullish.
914+
session_url = agent_session.get("url")
915+
if session_url is not None:
916+
comment_data["url"] = cast("str", session_url)
917+
918+
# `payload.promptContext ?? undefined` — nullish.
919+
prompt_context = payload.get("promptContext")
920+
raw = {
921+
"kind": "agent_session_comment",
922+
"organizationId": cast("str", payload.get("organizationId", "")),
923+
"comment": comment_data,
924+
"agentSessionId": cast("str", agent_session.get("id", "")),
925+
}
926+
if prompt_context is not None:
927+
raw["agentSessionPromptContext"] = cast("str", prompt_context)
928+
return self._parse_agent_session_message(raw)
929+
930+
self._logger.warn(
931+
"Unsupported agent session event action",
932+
{
933+
"action": action,
934+
"agentSessionId": agent_session.get("id"),
935+
"issueId": issue_id,
936+
},
937+
)
938+
return None
939+
940+
def _handle_agent_session_event(
941+
self,
942+
payload: AgentSessionEventWebhookPayload,
943+
options: WebhookOptions | None = None,
944+
) -> None:
945+
"""Handle an agent-session webhook event.
946+
947+
Faithful port of upstream ``handleAgentSessionEvent`` (index.ts:1269).
948+
Builds a message via :meth:`_parse_message_from_agent_session_event`
949+
and routes it to ``chat.process_message``. There is NO automatic
950+
acknowledgement — the bot does not auto-respond on receipt (no
951+
agentActivityCreate / typing / stream side-effect here; those land in
952+
L4). When the event cannot be parsed, logs a warning and returns.
953+
"""
954+
if not self._chat:
955+
self._logger.warn("Chat instance not initialized, ignoring agent session event")
956+
return
957+
958+
message = self._parse_message_from_agent_session_event(payload)
959+
if not message:
960+
self._logger.warn(
961+
"Unable to build message for Linear agent session event",
962+
{"agentSessionId": payload.get("agentSession", {}).get("id")},
963+
)
964+
return
965+
966+
self._chat.process_message(self, message.thread_id, message, options)
967+
641968
def _handle_reaction(self, payload: ReactionWebhookPayload) -> None:
642969
"""Handle reaction events (logging only)."""
643970
if not self._chat:

0 commit comments

Comments
 (0)