Skip to content

feat(linear): agent-session webhook parse + routing (chat@4.31/#151 — L3/5)#171

Merged
patrick-chinchill merged 1 commit into
mainfrom
feat/4.31-linear-agent-sessions-l3-webhook
Jun 20, 2026
Merged

feat(linear): agent-session webhook parse + routing (chat@4.31/#151 — L3/5)#171
patrick-chinchill merged 1 commit into
mainfrom
feat/4.31-linear-agent-sessions-l3-webhook

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Third of the 5-PR Wave D (#151). Builds on L1 types (#168) + L2 thread-id (#170, on main). Ports the agent-session inbound webhook path from upstream `adapter-linear/src/index.ts` (chat@4.31). Strictly scoped to PARSE + ROUTING — no agent-activity EMIT (L4) or FETCH (L5).

Mode-gating (index.ts:1144-1165)

  • `handle_webhook` now branches on `AgentSessionEvent` and mode-gates both event types:
    • Comment events only when `self._mode == "comments"` (existing data-change model).
    • AgentSessionEvent events only when `self._mode == "agent-sessions"`; any other mode logs the exact upstream warning and ignores.
  • The gates are mutually exclusive: a comment in agent-sessions mode (even one that `@`-mentions the bot's userName) and an agent event in comments mode are both dropped.

`_parse_message_from_agent_session_event` (index.ts:955)

  • `created` — a user mentioning the bot, opening a new session; builds the author from `agentSession.creator` (or the bot fallback when there's no creator), reads `payload.createdAt` as a raw string.
  • `prompted` — a follow-up in an existing session; builds the author from `agentActivity.user`.
  • null-return + warn paths: missing issueId, missing agentActivity, missing activity sourceCommentId, missing session comment, another bot's session (app-ownership guard), and an unsupported action.
  • `_handle_agent_session_event` (index.ts:1269) routes the parsed message to `chat.process_message` with no automatic acknowledgement (no outbound API call on receipt).
  • `get_user_name_from_profile_url` (utils.ts:40) — slug after `/profiles/`, `""` on non-match.

Nullish-vs-truthy fidelity (the #1 port risk)

Every upstream `??` / `?.` site is reproduced with is-not-None semantics, never truthiness:
`issueId ?? issue?.id`, `agentSession.url ?? undefined`, `promptContext ?? undefined`, `creator/activity avatarUrl ?? undefined`, `agentSession.comment?.id`. The app-ownership guard compares `appUserId != botUserId` on raw values so a foreign session is rejected and a None botUserId never falsely matches.

Tests (`tests/test_linear_webhook.py`, 31 tests)

created/prompted dispatch, both mode-gate directions, every null-return + warn path, app-ownership, bot-author fallback, createdAt-as-string, no-auto-ack, and the profile-url regex. Each FAILS under a plausible mutation (verified): `??`→`or` swap, mode-gate inversion (both directions), app-ownership `==`/`!=`, `is_mention` flip, url nullish→truthy.

Gauntlet

ruff check + ruff format --check + audit_test_quality.py (0 hard failures) + pyrefly (0 src errors, test file clean) + verify_test_fidelity (100%) + `pytest tests/` → 5156 passed, 4 skipped.

What L4/L5 consume

The mode gate, the decoded session thread (L2's `decode_thread_id`/`assert_agent_session_thread`), and the `agent_session_comment` raw message produced here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for Linear agent-session webhooks, enabling message parsing and routing for agent-session events.
  • Bug Fixes

    • Tightened webhook event dispatch with mode-based routing to prevent unintended webhook processing.
  • Tests

    • Added comprehensive test coverage for Linear agent-session webhook handling and mode-gating behavior.

… L3/5)

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.
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 27aafe1c-cd53-438f-acfa-8ca57e281d77

📥 Commits

Reviewing files that changed from the base of the PR and between 4d10adb and e83c6a6.

📒 Files selected for processing (3)
  • src/chat_sdk/adapters/linear/adapter.py
  • src/chat_sdk/adapters/linear/types.py
  • tests/test_linear_webhook.py

📝 Walkthrough

Walkthrough

The PR adds Linear agent-session webhook support to the adapter. It introduces LinearActorData and extends LinearCommentData in types.py, adds a profile URL regex/utility, gates webhook dispatch by mode in handle_webhook, implements three new methods for parsing and routing agent-session events, and adds 716 lines of pytest coverage.

Changes

Linear Agent-Session Webhook Support

Layer / File(s) Summary
Type contracts and profile URL utility
src/chat_sdk/adapters/linear/types.py, src/chat_sdk/adapters/linear/adapter.py, tests/test_linear_webhook.py
LinearActorData TypedDict and optional user field on LinearCommentData define the normalized author shape. PROFILE_URL_REGEX and get_user_name_from_profile_url extract display-name slugs from Linear profile URLs. Imports are expanded for agent-session payload symbols. Unit tests verify slug extraction, stop conditions, HTTPS anchoring, and non-match behavior.
Webhook dispatch mode gating
src/chat_sdk/adapters/linear/adapter.py, tests/test_linear_webhook.py
handle_webhook now requires mode="comments" for Comment create events and mode="agent-sessions" for AgentSessionEvent webhooks, logging a warning and ignoring events in the wrong mode. Tests assert process_message is not called for disallowed types and include a regression guard against inverted gating.
Agent-session parsing, routing, and tests
src/chat_sdk/adapters/linear/adapter.py, tests/test_linear_webhook.py
Three new methods: _parse_agent_session_message builds a Message from raw agent-session comment data with a re-encoded thread id and is_mention=True; _parse_message_from_agent_session_event handles "created"/"prompted" actions, constructs author from session creator or falls back to bot, and enforces app-ownership guards; _handle_agent_session_event routes to chat.process_message with init/build failure warnings. Tests cover created dispatch, prompted action, null-return paths, bot fallback, and nullish-vs-truthy regression guards.

Sequence Diagram(s)

sequenceDiagram
  participant Webhook as Linear Webhook
  participant Adapter as LinearAdapter.handle_webhook
  participant ModeGate as Mode Gate
  participant Parser as _parse_message_from_agent_session_event
  participant Builder as _parse_agent_session_message
  participant Chat as chat.process_message

  Webhook->>Adapter: POST AgentSessionEvent payload
  Adapter->>ModeGate: check mode == "agent-sessions"
  alt mode mismatch
    ModeGate-->>Adapter: warn + ignore
  else mode matches
    ModeGate->>Parser: validate issueId, action, app ownership
    alt "created" action
      Parser->>Builder: agent_session_comment + creator user data
      Builder-->>Parser: Message (re-encoded threadId, is_mention=True)
    else "prompted" action
      Parser->>Builder: agent_session_comment + activity user data
      Builder-->>Parser: Message
    else unsupported / missing fields
      Parser-->>Adapter: None + warning
    end
    Parser-->>Adapter: Message
    Adapter->>Chat: process_message(Message)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop, hop! A new session arrives,
The agent awakens and parsing thrives,
Mode gates click — comments stay in lane,
While agent-sessions take their own terrain,
Thread IDs re-encoded, mentions set True,
The rabbit reviews every edge case too! ✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Linear agent-session webhook events in the Linear adapter, including mode-gating, message parsing for both session creation and follow-up prompts, and comprehensive test coverage. The review feedback highlights several critical robustness improvements: defensively handling potential None values in get_user_name_from_profile_url to avoid TypeErrors, using or {} instead of dict.get(key, default) to prevent AttributeErrors when JSON keys are explicitly null, and using is not None checks rather than truthiness for creator to correctly handle empty dictionaries.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +128 to +141
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The get_user_name_from_profile_url function expects a string, but in webhook payloads, the url field can be None (null in JSON). Passing None to PROFILE_URL_REGEX.match will raise a TypeError: expected string or bytes-like object at runtime. We should defensively check if url is falsy/None before matching.

Suggested change
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 matchupstream 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 get_user_name_from_profile_url(url: str | None) -> 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 matchupstream returns the
empty string so the author's userName falls back to "" rather than
propagating an undefined.
"""
if not url:
return ""
match = PROFILE_URL_REGEX.match(url)
if not match:
return ""
return match.group(1)

"""
comment = raw["comment"]
text = cast("str", comment.get("body", ""))
user: LinearActorData = cast("LinearActorData", comment.get("user", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If comment.get("user") is None, using comment.get("user", {}) will return None instead of {}. This will cause an AttributeError when user.get(...) is called later. Using comment.get("user") or {} ensures we always fall back to an empty dictionary.

Suggested change
user: LinearActorData = cast("LinearActorData", comment.get("user", {}))
user: LinearActorData = cast("LinearActorData", comment.get("user") or {})

Any other action logs an "Unsupported agent session event action"
warning and returns ``None``.
"""
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If payload.get("agentSession") is None, using payload.get("agentSession", {}) will return None instead of {}. This will cause an AttributeError when agent_session.get(...) is called later. Using payload.get("agentSession") or {} ensures we always fall back to an empty dictionary.

Suggested change
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession", {}))
agent_session = cast("AgentSessionWebhookPayload", payload.get("agentSession") or {})

Comment on lines +804 to +805
content = agent_activity.get("content", {})
activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If agent_activity.get("content") or agent_activity.get("user") is None, using get(..., {}) will return None instead of {}. This will cause an AttributeError when content.get(...) or activity_user.get(...) is called later. Using or {} ensures we always fall back to an empty dictionary.

Suggested change
content = agent_activity.get("content", {})
activity_user = cast("AgentSessionUserChild", agent_activity.get("user", {}))
content = agent_activity.get("content") or {}
activity_user = cast("AgentSessionUserChild", agent_activity.get("user") or {})

Comment on lines +960 to +963
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": payload.get("agentSession", {}).get("id")},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python, dict.get(key, default) returns None if the key exists and its value is None (null in JSON). If payload.get("agentSession") is None, using payload.get("agentSession", {}) will return None instead of {}. This will cause an AttributeError when calling .get("id") on it. Using (payload.get("agentSession") or {}) ensures we always fall back to an empty dictionary.

Suggested change
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": payload.get("agentSession", {}).get("id")},
)
self._logger.warn(
"Unable to build message for Linear agent session event",
{"agentSessionId": (payload.get("agentSession") or {}).get("id")},
)

Comment on lines +876 to +878
creator = agent_session.get("creator")
user: LinearActorData
if creator:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When checking for optional values that can be falsy but valid (such as an empty dictionary {}), using a truthiness check like if creator: can silently ignore them because an empty dictionary is falsy in Python. To preserve the upstream TS behavior where empty objects are truthy, we should use is not None instead.

Suggested change
creator = agent_session.get("creator")
user: LinearActorData
if creator:
creator = agent_session.get("creator")
user: LinearActorData
if creator is not None:
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 20, 2026 06:45
@patrick-chinchill patrick-chinchill merged commit baed3f6 into main Jun 20, 2026
8 of 9 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e83c6a68c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +868 to +874
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle issue-level agent sessions without comments

When a created AgentSessionEvent comes from issue assignment/delegation or an issue-level session, agentSession.comment can legitimately be absent; this repo already supports the linear:{issue}:s:{session} thread form for that case. Returning here drops the verified webhook before chat.process_message, so the agent never starts for valid issue-level agent sessions. Please route these using the session-only thread id instead of treating the missing comment as unparsable.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant