Skip to content

Commit c0b7cf1

Browse files
feat(linear): agent-session types + kind discriminator (chat@4.31/#151 — L1/5)
Port the Linear agent-session type surface from upstream packages/adapter-linear/src/types.ts (vercel/chat, adapter-linear 4.27.0 / chat@4.31.0). TYPES + kind-discriminator plumbing only; agent-session webhook routing / emit / fetch logic lands in L3/L4/L5. - LinearThreadId gains optional agent_session_id; add LinearAgentSessionThreadId (required agent_session_id). - LinearAdapterConfig gains mode: Literal["agent-sessions", "comments"], default "comments" (config.mode ?? "comments", index.ts:236); exposed via the LinearAdapter.mode property. - LinearRawMessage becomes a discriminated union on kind: the existing comment variant (LinearCommentRawMessage, kind="comment") plus the new LinearAgentSessionCommentRawMessage (kind="agent_session_comment", agentSessionId, optional agentSessionPromptContext). All four existing comment producers in adapter.py now set kind="comment" (emit/parse symmetry — a raw message without kind would break the union). - Hand-author AgentSessionEventWebhookPayload (+ nested SDK-mirror TypedDicts) to match the @linear/sdk/webhooks shape upstream index.ts consumes; raw Linear camelCase wire keys kept verbatim. Extend the LinearWebhookPayload union to include it. Re-export the new public types from the linear package __init__ for L2–L5. Tests: structural/typing coverage (mode default, thread-id fields, kind discriminator on every existing producer, agent-session variant + webhook payload shape). pyrefly-clean (src 0, test file 0 / 1 justified ignore).
1 parent bf2963a commit c0b7cf1

4 files changed

Lines changed: 615 additions & 12 deletions

File tree

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
"""Linear adapter for chat-sdk."""
22

33
from chat_sdk.adapters.linear.adapter import LinearAdapter, create_linear_adapter
4-
from chat_sdk.adapters.linear.types import LinearInstallation
4+
from chat_sdk.adapters.linear.types import (
5+
AgentSessionEventWebhookPayload,
6+
LinearAdapterMode,
7+
LinearAgentSessionCommentRawMessage,
8+
LinearAgentSessionThreadId,
9+
LinearCommentRawMessage,
10+
LinearInstallation,
11+
LinearRawMessage,
12+
LinearThreadId,
13+
)
514

6-
__all__ = ["LinearAdapter", "LinearInstallation", "create_linear_adapter"]
15+
__all__ = [
16+
"AgentSessionEventWebhookPayload",
17+
"LinearAdapter",
18+
"LinearAdapterMode",
19+
"LinearAgentSessionCommentRawMessage",
20+
"LinearAgentSessionThreadId",
21+
"LinearCommentRawMessage",
22+
"LinearInstallation",
23+
"LinearRawMessage",
24+
"LinearThreadId",
25+
"create_linear_adapter",
26+
]

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
CommentWebhookPayload,
2626
LinearAdapterBaseConfig,
2727
LinearAdapterConfig,
28+
LinearAdapterMode,
2829
LinearCommentData,
30+
LinearCommentRawMessage,
2931
LinearInstallation,
3032
LinearRawMessage,
3133
LinearThreadId,
@@ -133,6 +135,13 @@ def __init__(self, config: LinearAdapterConfig | None = None) -> None:
133135
self._webhook_secret = webhook_secret
134136
self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear")
135137
self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot")
138+
# Inbound webhook handling model. Faithful port of upstream
139+
# ``this.mode = config.mode ?? "comments"`` (index.ts:236). "comments"
140+
# is the data-change webhook model (existing behavior); "agent-sessions"
141+
# is the app-actor model. The agent-session routing/emit/fetch logic
142+
# lands in later waves (L3/L4/L5); L1 only plumbs the field.
143+
config_mode: LinearAdapterMode | None = getattr(config, "mode", None)
144+
self._mode: LinearAdapterMode = config_mode if config_mode is not None else "comments"
136145
self._chat: ChatInstance | None = None
137146
self._bot_user_id: str | None = None
138147
self._format_converter = LinearFormatConverter()
@@ -220,6 +229,14 @@ def user_name(self) -> str:
220229
def bot_user_id(self) -> str | None:
221230
return self._bot_user_id
222231

232+
@property
233+
def mode(self) -> LinearAdapterMode:
234+
"""Inbound webhook handling model ("comments" or "agent-sessions").
235+
236+
Faithful port of upstream ``protected readonly mode`` (index.ts:201).
237+
"""
238+
return self._mode
239+
223240
@property
224241
def lock_scope(self) -> LockScope | None:
225242
return None
@@ -644,7 +661,7 @@ def _build_message(
644661
thread_id=thread_id,
645662
text=text,
646663
formatted=formatted,
647-
raw=LinearRawMessage(comment=comment),
664+
raw=LinearCommentRawMessage(kind="comment", comment=comment),
648665
author=author,
649666
metadata=MessageMetadata(
650667
date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc),
@@ -702,7 +719,8 @@ async def post_message(
702719
return RawMessage(
703720
id=comment_data.get("id", ""),
704721
thread_id=thread_id,
705-
raw=LinearRawMessage(
722+
raw=LinearCommentRawMessage(
723+
kind="comment",
706724
comment={
707725
"id": comment_data.get("id", ""),
708726
"body": comment_data.get("body", ""),
@@ -755,7 +773,8 @@ async def edit_message(
755773
return RawMessage(
756774
id=comment_data.get("id", ""),
757775
thread_id=thread_id,
758-
raw=LinearRawMessage(
776+
raw=LinearCommentRawMessage(
777+
kind="comment",
759778
comment={
760779
"id": comment_data.get("id", ""),
761780
"body": comment_data.get("body", ""),
@@ -962,7 +981,8 @@ def _comment_node_to_message(
962981
thread_id=thread_id,
963982
text=node.get("body", ""),
964983
formatted=self._format_converter.to_ast(node.get("body", "")),
965-
raw=LinearRawMessage(
984+
raw=LinearCommentRawMessage(
985+
kind="comment",
966986
comment={
967987
"id": node.get("id", ""),
968988
"body": node.get("body", ""),

src/chat_sdk/adapters/linear/types.py

Lines changed: 212 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,33 @@
22
33
Based on the Linear API and webhook format.
44
See: https://linear.app/developers
5+
6+
Agent-session types (``mode``, the ``kind`` discriminator, the agent-session
7+
raw-message variant, and ``AgentSessionEventWebhookPayload``) are a faithful
8+
port of upstream ``packages/adapter-linear/src/types.ts`` (vercel/chat,
9+
adapter-linear 4.27.0 / chat@4.31.0). Upstream re-uses ``@linear/sdk`` and
10+
``@linear/sdk/webhooks`` types; our adapter is raw GraphQL, so the
11+
agent-session webhook payload is hand-authored to mirror the SDK shape that
12+
upstream ``index.ts`` consumes.
513
"""
614

715
from __future__ import annotations
816

917
from dataclasses import dataclass
10-
from typing import Any, TypedDict
18+
from typing import Any, Literal, TypedDict
1119

1220
from chat_sdk.logger import Logger
1321

1422
# =============================================================================
1523
# Configuration
1624
# =============================================================================
1725

26+
# Incoming webhook handling mode for the Linear adapter. Mirrors upstream
27+
# ``LinearAdapterMode`` (types.ts:44). ``"comments"`` is the data-change
28+
# webhook model (the existing behavior); ``"agent-sessions"`` is the app-actor
29+
# model used for ``AgentSessionEvent`` webhooks.
30+
LinearAdapterMode = Literal["agent-sessions", "comments"]
31+
1832

1933
@dataclass
2034
class LinearAdapterBaseConfig:
@@ -28,6 +42,11 @@ class LinearAdapterBaseConfig:
2842
api_url: str | None = None
2943
# Logger instance for error reporting. Defaults to ConsoleLogger.
3044
logger: Logger | None = None
45+
# Controls which inbound Linear webhook model should trigger message
46+
# handling. Defaults to "comments". Use "agent-sessions" for app-actor
47+
# installs. Faithful port of upstream ``config.mode ?? "comments"``
48+
# (types.ts:67, index.ts:236).
49+
mode: LinearAdapterMode | None = None
3150
# Bot display name for @-mention detection.
3251
# Defaults to LINEAR_BOT_USERNAME env var or "linear-bot".
3352
user_name: str | None = None
@@ -130,6 +149,23 @@ class LinearThreadId:
130149
issue_id: str
131150
# Root comment ID for comment-level threads (optional)
132151
comment_id: str | None = None
152+
# Agent session UUID for app-actor interactions (optional). Faithful port of
153+
# upstream ``LinearThreadId.agentSessionId`` (types.ts:189).
154+
agent_session_id: str | None = None
155+
156+
157+
@dataclass(frozen=True)
158+
class LinearAgentSessionThreadId(LinearThreadId):
159+
"""Decoded thread ID for Linear threads associated with agent sessions.
160+
161+
Faithful port of upstream ``LinearAgentSessionThreadId``
162+
(``LinearThreadId & { agentSessionId: string }``, types.ts:203). Narrows
163+
``agent_session_id`` to a required, non-optional ``str`` so that downstream
164+
agent-session code (L4) can rely on the field being present.
165+
"""
166+
167+
# Required for agent-session threads (narrows the optional base field).
168+
agent_session_id: str = ""
133169

134170

135171
# =============================================================================
@@ -222,19 +258,189 @@ class ReactionWebhookPayload(TypedDict, total=False):
222258
webhookTimestamp: int
223259

224260

261+
# -----------------------------------------------------------------------------
262+
# Agent-session webhook payload (mode="agent-sessions")
263+
# -----------------------------------------------------------------------------
264+
#
265+
# Hand-authored to mirror upstream ``@linear/sdk/webhooks``
266+
# ``AgentSessionEventWebhookPayload`` as consumed by upstream
267+
# ``adapter-linear/src/index.ts``. Webhook payloads are external JSON, so the
268+
# RAW Linear wire keys (camelCase) are kept verbatim — no snake_case aliasing.
269+
# These are NetworkError-prone external inputs; ``total=False`` mirrors the
270+
# optional-heavy SDK shape and lets the parse logic (L3) read fields defensively.
271+
272+
273+
class AgentSessionCommentChild(TypedDict, total=False):
274+
"""Root comment of the thread an agent session is attached to.
275+
276+
Mirrors ``CommentChildWebhookPayload`` as consumed for
277+
``agentSession.comment`` (index.ts:1026-1035).
278+
"""
279+
280+
id: str
281+
body: str
282+
userId: str
283+
284+
285+
class AgentSessionIssueChild(TypedDict, total=False):
286+
"""Issue an agent session is associated with.
287+
288+
Mirrors ``IssueWithDescriptionChildWebhookPayload`` as consumed for
289+
``agentSession.issue`` (index.ts:959).
290+
"""
291+
292+
id: str
293+
294+
295+
class AgentSessionUserChild(TypedDict, total=False):
296+
"""Human user responsible for the agent session.
297+
298+
Mirrors ``UserChildWebhookPayload`` as consumed for
299+
``agentSession.creator`` (index.ts:1037-1044).
300+
"""
301+
302+
id: str
303+
name: str
304+
email: str
305+
avatarUrl: str
306+
url: str
307+
308+
309+
class AgentSessionWebhookPayload(TypedDict, total=False):
310+
"""The agent session an ``AgentSessionEvent`` belongs to.
311+
312+
Mirrors ``AgentSessionWebhookPayload`` (@linear/sdk/webhooks) as consumed by
313+
upstream ``index.ts``.
314+
"""
315+
316+
id: str
317+
appUserId: str
318+
issueId: str
319+
issue: AgentSessionIssueChild
320+
commentId: str
321+
sourceCommentId: str
322+
comment: AgentSessionCommentChild
323+
creator: AgentSessionUserChild
324+
url: str
325+
status: str
326+
summary: str
327+
sourceMetadata: dict[str, Any]
328+
329+
330+
class AgentActivityWebhookContent(TypedDict, total=False):
331+
"""Content of an agent activity (e.g. the prompt body).
332+
333+
Mirrors the ``content`` discriminated object consumed at index.ts:984.
334+
"""
335+
336+
type: str # e.g. "prompt"
337+
body: str
338+
339+
340+
class AgentActivityWebhookPayload(TypedDict, total=False):
341+
"""The agent activity that triggered a ``prompted`` event.
342+
343+
Mirrors ``AgentActivityWebhookPayload`` (@linear/sdk/webhooks) as consumed
344+
by upstream ``index.ts`` (e.g. index.ts:968-998).
345+
"""
346+
347+
id: str
348+
sourceCommentId: str
349+
content: AgentActivityWebhookContent
350+
user: AgentSessionUserChild
351+
createdAt: str
352+
353+
354+
class GuidanceRuleWebhookPayload(TypedDict, total=False):
355+
"""A single guidance rule for the agent's behavior."""
356+
357+
body: str
358+
359+
360+
class CommentChildWebhookPayload(TypedDict, total=False):
361+
"""A comment in the thread before the agent session was initiated."""
362+
363+
id: str
364+
body: str
365+
366+
367+
class AgentSessionEventWebhookPayload(TypedDict, total=False):
368+
"""Webhook payload for ``AgentSessionEvent`` events (mode="agent-sessions").
369+
370+
Faithful port of upstream ``AgentSessionEventWebhookPayload``
371+
(@linear/sdk/webhooks), hand-authored to match the shape consumed by
372+
upstream ``adapter-linear/src/index.ts``. Raw Linear wire keys (camelCase).
373+
"""
374+
375+
type: str # "AgentSessionEvent"
376+
action: str # "created" | "prompted"
377+
createdAt: str
378+
appUserId: str
379+
oauthClientId: str
380+
organizationId: str
381+
webhookId: str
382+
webhookTimestamp: int
383+
# Formatted prompt with relevant context; present only for "created" events.
384+
promptContext: str
385+
agentSession: AgentSessionWebhookPayload
386+
agentActivity: AgentActivityWebhookPayload
387+
guidance: list[GuidanceRuleWebhookPayload]
388+
previousComments: list[CommentChildWebhookPayload]
389+
390+
225391
# Union type for all webhook payloads
226-
LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload
392+
LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload | AgentSessionEventWebhookPayload
227393

228394

229395
# =============================================================================
230396
# Raw Message Type
231397
# =============================================================================
398+
#
399+
# Discriminated union on the ``kind`` field. Faithful port of upstream
400+
# ``LinearRawMessage`` (types.ts:279). Every variant MUST carry ``kind`` so the
401+
# union discriminates cleanly; constructing a raw message without ``kind``
402+
# breaks the union (emit/parse symmetry). The existing (comment-based)
403+
# producers in ``adapter.py`` all set ``kind="comment"``.
404+
405+
406+
class LinearCommentRawMessage(TypedDict, total=False):
407+
"""Platform-specific raw message for a standard Linear comment.
408+
409+
Faithful port of upstream ``LinearCommentRawMessage`` (types.ts:258).
410+
``kind`` and ``comment`` are required; ``organizationId`` is part of the
411+
upstream base but our raw-GraphQL producers do not always have it on hand,
412+
so it stays optional (``total=False``) for back-compat with the existing
413+
comment path.
414+
"""
415+
416+
# Raw message kind discriminator. Always "comment" for this variant.
417+
kind: Literal["comment"]
418+
# The raw comment data from webhook or API.
419+
comment: LinearCommentData
420+
# Organization ID from the webhook or request context.
421+
organizationId: str
422+
232423

424+
class LinearAgentSessionCommentRawMessage(TypedDict, total=False):
425+
"""Platform-specific raw message for a comment backed by an agent session.
233426
234-
class LinearRawMessage(TypedDict, total=False):
235-
"""Platform-specific raw message type for Linear."""
427+
Faithful port of upstream ``LinearAgentSessionCommentRawMessage``
428+
(types.ts:265). Carries the agent session the comment belongs to plus an
429+
optional prompt context.
430+
"""
236431

237-
# The raw comment data from webhook or API
432+
# Raw message kind discriminator. Always "agent_session_comment".
433+
kind: Literal["agent_session_comment"]
434+
# The visible Linear comment backing this message.
238435
comment: LinearCommentData
239-
# Organization ID from the webhook
436+
# The agent session the comment belongs to.
437+
agentSessionId: str
438+
# The prompt context associated with this agent session comment (optional).
439+
agentSessionPromptContext: str
440+
# Organization ID from the webhook or request context.
240441
organizationId: str
442+
443+
444+
# Platform-specific raw message type for Linear (discriminated union on `kind`).
445+
# Faithful port of upstream ``LinearRawMessage`` (types.ts:279).
446+
LinearRawMessage = LinearCommentRawMessage | LinearAgentSessionCommentRawMessage

0 commit comments

Comments
 (0)