From c0b7cf1bcbd55f660bb40d1e6b416f6724a8d3c8 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Fri, 19 Jun 2026 22:48:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(linear):=20agent-session=20types=20+=20kin?= =?UTF-8?q?d=20discriminator=20(chat@4.31/#151=20=E2=80=94=20L1/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/chat_sdk/adapters/linear/__init__.py | 24 +- src/chat_sdk/adapters/linear/adapter.py | 28 +- src/chat_sdk/adapters/linear/types.py | 218 +++++++++++++- tests/test_linear_agent_session_types.py | 357 +++++++++++++++++++++++ 4 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 tests/test_linear_agent_session_types.py diff --git a/src/chat_sdk/adapters/linear/__init__.py b/src/chat_sdk/adapters/linear/__init__.py index be505cb..3e19d71 100644 --- a/src/chat_sdk/adapters/linear/__init__.py +++ b/src/chat_sdk/adapters/linear/__init__.py @@ -1,6 +1,26 @@ """Linear adapter for chat-sdk.""" from chat_sdk.adapters.linear.adapter import LinearAdapter, create_linear_adapter -from chat_sdk.adapters.linear.types import LinearInstallation +from chat_sdk.adapters.linear.types import ( + AgentSessionEventWebhookPayload, + LinearAdapterMode, + LinearAgentSessionCommentRawMessage, + LinearAgentSessionThreadId, + LinearCommentRawMessage, + LinearInstallation, + LinearRawMessage, + LinearThreadId, +) -__all__ = ["LinearAdapter", "LinearInstallation", "create_linear_adapter"] +__all__ = [ + "AgentSessionEventWebhookPayload", + "LinearAdapter", + "LinearAdapterMode", + "LinearAgentSessionCommentRawMessage", + "LinearAgentSessionThreadId", + "LinearCommentRawMessage", + "LinearInstallation", + "LinearRawMessage", + "LinearThreadId", + "create_linear_adapter", +] diff --git a/src/chat_sdk/adapters/linear/adapter.py b/src/chat_sdk/adapters/linear/adapter.py index 532ffce..d764ba3 100644 --- a/src/chat_sdk/adapters/linear/adapter.py +++ b/src/chat_sdk/adapters/linear/adapter.py @@ -25,7 +25,9 @@ CommentWebhookPayload, LinearAdapterBaseConfig, LinearAdapterConfig, + LinearAdapterMode, LinearCommentData, + LinearCommentRawMessage, LinearInstallation, LinearRawMessage, LinearThreadId, @@ -133,6 +135,13 @@ def __init__(self, config: LinearAdapterConfig | None = None) -> None: self._webhook_secret = webhook_secret self._logger: Logger = getattr(config, "logger", None) or ConsoleLogger("info", prefix="linear") self._user_name = getattr(config, "user_name", None) or os.environ.get("LINEAR_BOT_USERNAME", "linear-bot") + # Inbound webhook handling model. Faithful port of upstream + # ``this.mode = config.mode ?? "comments"`` (index.ts:236). "comments" + # is the data-change webhook model (existing behavior); "agent-sessions" + # is the app-actor model. The agent-session routing/emit/fetch logic + # lands in later waves (L3/L4/L5); L1 only plumbs the field. + config_mode: LinearAdapterMode | None = getattr(config, "mode", None) + self._mode: LinearAdapterMode = config_mode if config_mode is not None else "comments" self._chat: ChatInstance | None = None self._bot_user_id: str | None = None self._format_converter = LinearFormatConverter() @@ -220,6 +229,14 @@ def user_name(self) -> str: def bot_user_id(self) -> str | None: return self._bot_user_id + @property + def mode(self) -> LinearAdapterMode: + """Inbound webhook handling model ("comments" or "agent-sessions"). + + Faithful port of upstream ``protected readonly mode`` (index.ts:201). + """ + return self._mode + @property def lock_scope(self) -> LockScope | None: return None @@ -644,7 +661,7 @@ def _build_message( thread_id=thread_id, text=text, formatted=formatted, - raw=LinearRawMessage(comment=comment), + raw=LinearCommentRawMessage(kind="comment", comment=comment), author=author, metadata=MessageMetadata( date_sent=_parse_iso(created_at) if created_at else datetime.now(timezone.utc), @@ -702,7 +719,8 @@ async def post_message( return RawMessage( id=comment_data.get("id", ""), thread_id=thread_id, - raw=LinearRawMessage( + raw=LinearCommentRawMessage( + kind="comment", comment={ "id": comment_data.get("id", ""), "body": comment_data.get("body", ""), @@ -755,7 +773,8 @@ async def edit_message( return RawMessage( id=comment_data.get("id", ""), thread_id=thread_id, - raw=LinearRawMessage( + raw=LinearCommentRawMessage( + kind="comment", comment={ "id": comment_data.get("id", ""), "body": comment_data.get("body", ""), @@ -962,7 +981,8 @@ def _comment_node_to_message( thread_id=thread_id, text=node.get("body", ""), formatted=self._format_converter.to_ast(node.get("body", "")), - raw=LinearRawMessage( + raw=LinearCommentRawMessage( + kind="comment", comment={ "id": node.get("id", ""), "body": node.get("body", ""), diff --git a/src/chat_sdk/adapters/linear/types.py b/src/chat_sdk/adapters/linear/types.py index 68fb616..1f4c76c 100644 --- a/src/chat_sdk/adapters/linear/types.py +++ b/src/chat_sdk/adapters/linear/types.py @@ -2,12 +2,20 @@ Based on the Linear API and webhook format. See: https://linear.app/developers + +Agent-session types (``mode``, the ``kind`` discriminator, the agent-session +raw-message variant, and ``AgentSessionEventWebhookPayload``) are a faithful +port of upstream ``packages/adapter-linear/src/types.ts`` (vercel/chat, +adapter-linear 4.27.0 / chat@4.31.0). Upstream re-uses ``@linear/sdk`` and +``@linear/sdk/webhooks`` types; our adapter is raw GraphQL, so the +agent-session webhook payload is hand-authored to mirror the SDK shape that +upstream ``index.ts`` consumes. """ from __future__ import annotations from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Any, Literal, TypedDict from chat_sdk.logger import Logger @@ -15,6 +23,12 @@ # Configuration # ============================================================================= +# Incoming webhook handling mode for the Linear adapter. Mirrors upstream +# ``LinearAdapterMode`` (types.ts:44). ``"comments"`` is the data-change +# webhook model (the existing behavior); ``"agent-sessions"`` is the app-actor +# model used for ``AgentSessionEvent`` webhooks. +LinearAdapterMode = Literal["agent-sessions", "comments"] + @dataclass class LinearAdapterBaseConfig: @@ -28,6 +42,11 @@ class LinearAdapterBaseConfig: api_url: str | None = None # Logger instance for error reporting. Defaults to ConsoleLogger. logger: Logger | None = None + # Controls which inbound Linear webhook model should trigger message + # handling. Defaults to "comments". Use "agent-sessions" for app-actor + # installs. Faithful port of upstream ``config.mode ?? "comments"`` + # (types.ts:67, index.ts:236). + mode: LinearAdapterMode | None = None # Bot display name for @-mention detection. # Defaults to LINEAR_BOT_USERNAME env var or "linear-bot". user_name: str | None = None @@ -130,6 +149,23 @@ class LinearThreadId: issue_id: str # Root comment ID for comment-level threads (optional) comment_id: str | None = None + # Agent session UUID for app-actor interactions (optional). Faithful port of + # upstream ``LinearThreadId.agentSessionId`` (types.ts:189). + agent_session_id: str | None = None + + +@dataclass(frozen=True) +class LinearAgentSessionThreadId(LinearThreadId): + """Decoded thread ID for Linear threads associated with agent sessions. + + Faithful port of upstream ``LinearAgentSessionThreadId`` + (``LinearThreadId & { agentSessionId: string }``, types.ts:203). Narrows + ``agent_session_id`` to a required, non-optional ``str`` so that downstream + agent-session code (L4) can rely on the field being present. + """ + + # Required for agent-session threads (narrows the optional base field). + agent_session_id: str = "" # ============================================================================= @@ -222,19 +258,189 @@ class ReactionWebhookPayload(TypedDict, total=False): webhookTimestamp: int +# ----------------------------------------------------------------------------- +# Agent-session webhook payload (mode="agent-sessions") +# ----------------------------------------------------------------------------- +# +# Hand-authored to mirror upstream ``@linear/sdk/webhooks`` +# ``AgentSessionEventWebhookPayload`` as consumed by upstream +# ``adapter-linear/src/index.ts``. Webhook payloads are external JSON, so the +# RAW Linear wire keys (camelCase) are kept verbatim — no snake_case aliasing. +# These are NetworkError-prone external inputs; ``total=False`` mirrors the +# optional-heavy SDK shape and lets the parse logic (L3) read fields defensively. + + +class AgentSessionCommentChild(TypedDict, total=False): + """Root comment of the thread an agent session is attached to. + + Mirrors ``CommentChildWebhookPayload`` as consumed for + ``agentSession.comment`` (index.ts:1026-1035). + """ + + id: str + body: str + userId: str + + +class AgentSessionIssueChild(TypedDict, total=False): + """Issue an agent session is associated with. + + Mirrors ``IssueWithDescriptionChildWebhookPayload`` as consumed for + ``agentSession.issue`` (index.ts:959). + """ + + id: str + + +class AgentSessionUserChild(TypedDict, total=False): + """Human user responsible for the agent session. + + Mirrors ``UserChildWebhookPayload`` as consumed for + ``agentSession.creator`` (index.ts:1037-1044). + """ + + id: str + name: str + email: str + avatarUrl: str + url: str + + +class AgentSessionWebhookPayload(TypedDict, total=False): + """The agent session an ``AgentSessionEvent`` belongs to. + + Mirrors ``AgentSessionWebhookPayload`` (@linear/sdk/webhooks) as consumed by + upstream ``index.ts``. + """ + + id: str + appUserId: str + issueId: str + issue: AgentSessionIssueChild + commentId: str + sourceCommentId: str + comment: AgentSessionCommentChild + creator: AgentSessionUserChild + url: str + status: str + summary: str + sourceMetadata: dict[str, Any] + + +class AgentActivityWebhookContent(TypedDict, total=False): + """Content of an agent activity (e.g. the prompt body). + + Mirrors the ``content`` discriminated object consumed at index.ts:984. + """ + + type: str # e.g. "prompt" + body: str + + +class AgentActivityWebhookPayload(TypedDict, total=False): + """The agent activity that triggered a ``prompted`` event. + + Mirrors ``AgentActivityWebhookPayload`` (@linear/sdk/webhooks) as consumed + by upstream ``index.ts`` (e.g. index.ts:968-998). + """ + + id: str + sourceCommentId: str + content: AgentActivityWebhookContent + user: AgentSessionUserChild + createdAt: str + + +class GuidanceRuleWebhookPayload(TypedDict, total=False): + """A single guidance rule for the agent's behavior.""" + + body: str + + +class CommentChildWebhookPayload(TypedDict, total=False): + """A comment in the thread before the agent session was initiated.""" + + id: str + body: str + + +class AgentSessionEventWebhookPayload(TypedDict, total=False): + """Webhook payload for ``AgentSessionEvent`` events (mode="agent-sessions"). + + Faithful port of upstream ``AgentSessionEventWebhookPayload`` + (@linear/sdk/webhooks), hand-authored to match the shape consumed by + upstream ``adapter-linear/src/index.ts``. Raw Linear wire keys (camelCase). + """ + + type: str # "AgentSessionEvent" + action: str # "created" | "prompted" + createdAt: str + appUserId: str + oauthClientId: str + organizationId: str + webhookId: str + webhookTimestamp: int + # Formatted prompt with relevant context; present only for "created" events. + promptContext: str + agentSession: AgentSessionWebhookPayload + agentActivity: AgentActivityWebhookPayload + guidance: list[GuidanceRuleWebhookPayload] + previousComments: list[CommentChildWebhookPayload] + + # Union type for all webhook payloads -LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload +LinearWebhookPayload = CommentWebhookPayload | ReactionWebhookPayload | AgentSessionEventWebhookPayload # ============================================================================= # Raw Message Type # ============================================================================= +# +# Discriminated union on the ``kind`` field. Faithful port of upstream +# ``LinearRawMessage`` (types.ts:279). Every variant MUST carry ``kind`` so the +# union discriminates cleanly; constructing a raw message without ``kind`` +# breaks the union (emit/parse symmetry). The existing (comment-based) +# producers in ``adapter.py`` all set ``kind="comment"``. + + +class LinearCommentRawMessage(TypedDict, total=False): + """Platform-specific raw message for a standard Linear comment. + + Faithful port of upstream ``LinearCommentRawMessage`` (types.ts:258). + ``kind`` and ``comment`` are required; ``organizationId`` is part of the + upstream base but our raw-GraphQL producers do not always have it on hand, + so it stays optional (``total=False``) for back-compat with the existing + comment path. + """ + + # Raw message kind discriminator. Always "comment" for this variant. + kind: Literal["comment"] + # The raw comment data from webhook or API. + comment: LinearCommentData + # Organization ID from the webhook or request context. + organizationId: str + +class LinearAgentSessionCommentRawMessage(TypedDict, total=False): + """Platform-specific raw message for a comment backed by an agent session. -class LinearRawMessage(TypedDict, total=False): - """Platform-specific raw message type for Linear.""" + Faithful port of upstream ``LinearAgentSessionCommentRawMessage`` + (types.ts:265). Carries the agent session the comment belongs to plus an + optional prompt context. + """ - # The raw comment data from webhook or API + # Raw message kind discriminator. Always "agent_session_comment". + kind: Literal["agent_session_comment"] + # The visible Linear comment backing this message. comment: LinearCommentData - # Organization ID from the webhook + # The agent session the comment belongs to. + agentSessionId: str + # The prompt context associated with this agent session comment (optional). + agentSessionPromptContext: str + # Organization ID from the webhook or request context. organizationId: str + + +# Platform-specific raw message type for Linear (discriminated union on `kind`). +# Faithful port of upstream ``LinearRawMessage`` (types.ts:279). +LinearRawMessage = LinearCommentRawMessage | LinearAgentSessionCommentRawMessage diff --git a/tests/test_linear_agent_session_types.py b/tests/test_linear_agent_session_types.py new file mode 100644 index 0000000..46054e4 --- /dev/null +++ b/tests/test_linear_agent_session_types.py @@ -0,0 +1,357 @@ +"""Structural / typing tests for Linear agent-session types (chat@4.31 / #151, L1). + +Faithful-port coverage for the agent-session type surface added in +``packages/adapter-linear/src/types.ts`` (vercel/chat): + +- ``LinearThreadId.agent_session_id`` (optional) + ``LinearAgentSessionThreadId`` + (required ``agent_session_id``). +- ``LinearAdapterConfig.mode`` (default ``"comments"``). +- The ``kind`` discriminator on ``LinearRawMessage`` and the new + ``LinearAgentSessionCommentRawMessage`` variant. CRITICAL: every existing + (comment) producer site in ``adapter.py`` must set ``kind="comment"`` so the + discriminated union stays well-formed (emit/parse symmetry). +- ``AgentSessionEventWebhookPayload`` joins the ``LinearWebhookPayload`` union. + +This file is kept pyrefly-clean (``uv run pyrefly check tests/`` → 0). +""" + +from __future__ import annotations + +import time +from typing import Literal, get_args +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.adapters.linear.adapter import LinearAdapter +from chat_sdk.adapters.linear.types import ( + AgentActivityWebhookPayload, + AgentSessionEventWebhookPayload, + AgentSessionWebhookPayload, + LinearAdapterAPIKeyConfig, + LinearAdapterBaseConfig, + LinearAdapterMode, + LinearAgentSessionCommentRawMessage, + LinearAgentSessionThreadId, + LinearCommentData, + LinearCommentRawMessage, + LinearRawMessage, + LinearThreadId, +) + +WEBHOOK_SECRET = "test-webhook-secret" + + +def _make_logger() -> MagicMock: + return MagicMock( + debug=MagicMock(), + info=MagicMock(), + warn=MagicMock(), + error=MagicMock(), + ) + + +def _make_adapter(mode: LinearAdapterMode | None = None) -> LinearAdapter: + config = LinearAdapterAPIKeyConfig( + api_key="test-api-key", + webhook_secret=WEBHOOK_SECRET, + user_name="test-bot", + logger=_make_logger(), + mode=mode, + ) + return LinearAdapter(config) + + +# --------------------------------------------------------------------------- +# Config: mode (default "comments") +# --------------------------------------------------------------------------- + + +class TestAdapterMode: + def test_mode_literal_members(self) -> None: + # Faithful to upstream LinearAdapterMode = "agent-sessions" | "comments". + assert set(get_args(LinearAdapterMode)) == {"agent-sessions", "comments"} + + def test_default_mode_is_comments(self) -> None: + # Upstream: this.mode = config.mode ?? "comments" (index.ts:236). + adapter = _make_adapter() + assert adapter.mode == "comments" + + def test_base_config_mode_defaults_to_none(self) -> None: + # The dataclass field defaults to None so the adapter resolves "comments". + assert LinearAdapterBaseConfig().mode is None + + def test_mode_override_agent_sessions(self) -> None: + adapter = _make_adapter(mode="agent-sessions") + assert adapter.mode == "agent-sessions" + + def test_mode_override_comments_explicit(self) -> None: + adapter = _make_adapter(mode="comments") + assert adapter.mode == "comments" + + +# --------------------------------------------------------------------------- +# Thread ID: agent_session_id (optional) + LinearAgentSessionThreadId +# --------------------------------------------------------------------------- + + +class TestThreadId: + def test_thread_id_agent_session_id_optional_default_none(self) -> None: + tid = LinearThreadId(issue_id="issue-1") + assert tid.agent_session_id is None + assert tid.comment_id is None + + def test_thread_id_carries_agent_session_id(self) -> None: + tid = LinearThreadId(issue_id="issue-1", agent_session_id="session-1") + assert tid.agent_session_id == "session-1" + + def test_agent_session_thread_id_is_subclass(self) -> None: + # Upstream: LinearAgentSessionThreadId = LinearThreadId & { agentSessionId }. + assert issubclass(LinearAgentSessionThreadId, LinearThreadId) + + def test_agent_session_thread_id_required_field(self) -> None: + tid = LinearAgentSessionThreadId(issue_id="issue-1", agent_session_id="session-9") + assert tid.agent_session_id == "session-9" + assert isinstance(tid, LinearThreadId) + + def test_agent_session_thread_id_frozen(self) -> None: + tid = LinearAgentSessionThreadId(issue_id="issue-1", agent_session_id="session-9") + with pytest.raises(Exception): # noqa: B017 - FrozenInstanceError (dataclasses) + tid.agent_session_id = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Raw message: kind discriminator + agent-session variant +# --------------------------------------------------------------------------- + + +def _comment_data() -> LinearCommentData: + return { + "id": "comment-1", + "body": "hello", + "issueId": "issue-1", + "userId": "user-1", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "url": "https://linear.app/test/comment/comment-1", + } + + +class TestRawMessageKind: + def test_comment_variant_carries_comment_kind(self) -> None: + raw: LinearCommentRawMessage = {"kind": "comment", "comment": _comment_data()} + assert raw["kind"] == "comment" + assert raw["comment"]["body"] == "hello" + + def test_agent_session_variant_type_checks_and_discriminates(self) -> None: + raw: LinearAgentSessionCommentRawMessage = { + "kind": "agent_session_comment", + "comment": _comment_data(), + "agentSessionId": "session-1", + "agentSessionPromptContext": "Issue TEST-1\n\n@bot Hello", + "organizationId": "org-1", + } + assert raw["kind"] == "agent_session_comment" + assert raw["agentSessionId"] == "session-1" + assert raw["agentSessionPromptContext"].startswith("Issue TEST-1") + + def test_agent_session_prompt_context_optional(self) -> None: + # agentSessionPromptContext is optional upstream (only on "created" events). + raw: LinearAgentSessionCommentRawMessage = { + "kind": "agent_session_comment", + "comment": _comment_data(), + "agentSessionId": "session-1", + } + assert "agentSessionPromptContext" not in raw + + def test_raw_message_union_discriminates_on_kind(self) -> None: + messages: list[LinearRawMessage] = [ + {"kind": "comment", "comment": _comment_data()}, + { + "kind": "agent_session_comment", + "comment": _comment_data(), + "agentSessionId": "session-1", + }, + ] + kinds = [m["kind"] for m in messages] + assert kinds == ["comment", "agent_session_comment"] + + +# --------------------------------------------------------------------------- +# kind-discriminator audit: every EXISTING comment producer sets kind="comment". +# These are the four LinearCommentRawMessage construction sites in adapter.py. +# --------------------------------------------------------------------------- + + +class TestExistingProducersSetCommentKind: + @pytest.mark.asyncio + async def test_post_message_sets_comment_kind(self) -> None: + adapter = _make_adapter() + adapter._ensure_valid_token = AsyncMock() + adapter._graphql_query = AsyncMock( + return_value={ + "data": { + "commentCreate": { + "success": True, + "comment": { + "id": "new-comment-1", + "body": "Bot reply", + "url": "https://linear.app/test/comment/new-comment-1", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + }, + } + } + } + ) + + result = await adapter.post_message("linear:issue-123", "Hello from bot") + + assert result.raw["kind"] == "comment" + assert result.raw["comment"]["body"] == "Bot reply" + + @pytest.mark.asyncio + async def test_edit_message_sets_comment_kind(self) -> None: + adapter = _make_adapter() + adapter._ensure_valid_token = AsyncMock() + adapter._graphql_query = AsyncMock( + return_value={ + "data": { + "commentUpdate": { + "success": True, + "comment": { + "id": "comment-1", + "body": "Updated body", + "url": "https://linear.app/test/comment/comment-1", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:05:00.000Z", + }, + } + } + } + ) + + result = await adapter.edit_message("linear:issue-123", "comment-1", "Updated body") + + assert result.raw["kind"] == "comment" + assert result.raw["comment"]["body"] == "Updated body" + + def test_comment_node_to_message_sets_comment_kind(self) -> None: + adapter = _make_adapter() + node = { + "id": "comment-9", + "body": "Some text", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "url": "https://linear.app/test/comment/comment-9", + "user": {"id": "user-1", "displayName": "Test", "name": "Test User"}, + } + + message = adapter._comment_node_to_message(node, "linear:issue-1", "issue-1") + + assert message.raw["kind"] == "comment" + assert message.raw["comment"]["body"] == "Some text" + + def test_webhook_build_message_sets_comment_kind(self) -> None: + adapter = _make_adapter() + comment: LinearCommentData = _comment_data() + actor = {"id": "user-1", "name": "Test User", "type": "user"} + + message = adapter._build_message(comment, actor, "linear:issue-1") + + assert message.raw["kind"] == "comment" + assert message.raw["comment"]["body"] == "hello" + + +# --------------------------------------------------------------------------- +# AgentSessionEventWebhookPayload: structural mirror of upstream SDK shape +# and membership in the LinearWebhookPayload union. +# --------------------------------------------------------------------------- + + +def _agent_session_payload() -> AgentSessionEventWebhookPayload: + session: AgentSessionWebhookPayload = { + "id": "agent-session-1", + "appUserId": "bot-user-id", + "issueId": "issue-123", + "commentId": "comment-root", + "sourceCommentId": "comment-source", + "comment": {"id": "comment-root", "body": "@test-bot Hello", "userId": "user-456"}, + "creator": { + "id": "user-456", + "name": "Test User", + "url": "https://linear.app/test/profiles/test-user", + }, + "url": "https://linear.app/test/session/agent-session-1", + "status": "active", + "summary": "Help with the issue", + } + activity: AgentActivityWebhookPayload = { + "id": "agent-activity-1", + "sourceCommentId": "comment-source", + "content": {"type": "prompt", "body": "Hello from app actor"}, + "createdAt": "2025-06-01T12:00:00.000Z", + } + return { + "type": "AgentSessionEvent", + "action": "created", + "createdAt": "2025-06-01T12:00:00.000Z", + "appUserId": "bot-user-id", + "oauthClientId": "oauth-client-123", + "organizationId": "org-123", + "webhookId": "webhook-agent-1", + "webhookTimestamp": int(time.time() * 1000), + "promptContext": "Issue TEST-1\n\n@test-bot Hello there", + "agentSession": session, + "agentActivity": activity, + "guidance": [{"body": "Be concise"}], + "previousComments": [{"id": "previous-comment-1", "body": "Previous discussion"}], + } + + +class TestAgentSessionEventWebhookPayload: + def test_payload_structural_shape(self) -> None: + payload = _agent_session_payload() + assert payload["type"] == "AgentSessionEvent" + assert payload["action"] == "created" + assert payload["agentSession"]["issueId"] == "issue-123" + assert payload["agentSession"]["comment"]["id"] == "comment-root" + assert payload["agentSession"]["creator"]["name"] == "Test User" + assert payload["agentActivity"]["content"]["body"] == "Hello from app actor" + assert payload["promptContext"].startswith("Issue TEST-1") + + def test_payload_assignable_to_webhook_union(self) -> None: + # AgentSessionEventWebhookPayload must be a member of LinearWebhookPayload. + from chat_sdk.adapters.linear.types import LinearWebhookPayload + + members = get_args(LinearWebhookPayload) + assert AgentSessionEventWebhookPayload in members + + def test_prompt_context_absent_for_prompted_action(self) -> None: + # promptContext is present only for "created" events upstream; a + # "prompted" payload omitting it must still type-check (total=False). + payload: AgentSessionEventWebhookPayload = { + "type": "AgentSessionEvent", + "action": "prompted", + "organizationId": "org-123", + "agentSession": {"id": "s1", "issueId": "issue-1"}, + "agentActivity": { + "id": "a1", + "sourceCommentId": "c1", + "content": {"type": "prompt", "body": "next"}, + }, + } + assert "promptContext" not in payload + assert payload["action"] == "prompted" + + +# --------------------------------------------------------------------------- +# Discriminator value sanity: the literals match upstream exactly. +# --------------------------------------------------------------------------- + + +def test_kind_literal_values_match_upstream() -> None: + comment_kind: Literal["comment"] = "comment" + agent_kind: Literal["agent_session_comment"] = "agent_session_comment" + assert comment_kind == "comment" + assert agent_kind == "agent_session_comment"