Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/chat_sdk/adapters/linear/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Linear adapter for chat-sdk."""

from chat_sdk.adapters.linear.adapter import LinearAdapter, create_linear_adapter
from chat_sdk.adapters.linear.adapter import (
LinearAdapter,
assert_agent_session_thread,
create_linear_adapter,
)
from chat_sdk.adapters.linear.types import (
AgentSessionEventWebhookPayload,
LinearAdapterMode,
Expand All @@ -22,5 +26,6 @@
"LinearInstallation",
"LinearRawMessage",
"LinearThreadId",
"assert_agent_session_thread",
"create_linear_adapter",
]
72 changes: 70 additions & 2 deletions src/chat_sdk/adapters/linear/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
LinearAdapterBaseConfig,
LinearAdapterConfig,
LinearAdapterMode,
LinearAgentSessionThreadId,
LinearCommentData,
LinearCommentRawMessage,
LinearInstallation,
Expand Down Expand Up @@ -76,7 +77,16 @@
_parse_iso,
)

# Anchored thread-id patterns (most-specific first). Faithful ports of the
# upstream regexes in ``adapter-linear/src/index.ts`` (4.31/#151). All three
# carry explicit ``^...$`` anchors so ``.match()`` is a *full* match — an
# un-anchored pattern would mis-parse a thread id (e.g. silently truncate a
# trailing ``:s:{session}`` segment). The decode order COMMENT_SESSION →
# ISSUE_SESSION → COMMENT → bare-issue is load-bearing: each later form is a
# prefix-shaped subset of an earlier one, so a wrong order mis-routes ids.
COMMENT_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+):s:([^:]+)$")
COMMENT_THREAD_PATTERN = re.compile(r"^([^:]+):c:([^:]+)$")
ISSUE_SESSION_THREAD_PATTERN = re.compile(r"^([^:]+):s:([^:]+)$")

# Linear GraphQL API endpoint
LINEAR_API_URL = "https://api.linear.app/graphql"
Expand All @@ -103,6 +113,24 @@
}


def assert_agent_session_thread(
thread: LinearThreadId,
) -> LinearAgentSessionThreadId:
"""Narrow a decoded thread to the agent-session case before session-only work.

Faithful port of upstream ``assertAgentSessionThread`` (``utils.ts``). The TS
signature is ``asserts thread is LinearAgentSessionThreadId`` (a void
type-guard). Python has no in-place assertion narrowing for dataclasses, so
this returns the same thread re-typed as ``LinearAgentSessionThreadId`` —
callers can either ignore the return (assertion side-effect) or bind it to
get the narrowed type. Raises ``ValidationError`` when the thread carries no
``agent_session_id``, matching the upstream message byte-for-byte.
"""
if not thread.agent_session_id:
raise ValidationError("linear", "Expected a Linear agent session thread")
return cast("LinearAgentSessionThreadId", thread)
Comment on lines +129 to +131

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

In Python, typing.cast is a static analysis helper and does not perform any runtime type conversion. Since LinearAgentSessionThreadId is a distinct subclass of LinearThreadId, using cast means the returned object is still a LinearThreadId instance at runtime. Any subsequent runtime checks (such as isinstance(thread, LinearAgentSessionThreadId)) will fail. To ensure runtime type safety and correct behavior, we should instantiate and return a real LinearAgentSessionThreadId object. Additionally, ensure we check if agent_session_id is None explicitly rather than using a truthiness check, to avoid issues if an empty string is a valid ID.

    if thread.agent_session_id is None:
        raise ValidationError("linear", "Expected a Linear agent session thread")
    return LinearAgentSessionThreadId(
        issue_id=thread.issue_id,
        comment_id=thread.comment_id,
        agent_session_id=thread.agent_session_id,
    )
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.



class LinearAdapter:
"""Linear adapter for chat SDK.

Expand Down Expand Up @@ -1052,21 +1080,61 @@ def encode_thread_id(self, platform_data: LinearThreadId) -> str:
Formats:
- Issue-level: linear:{issue_id}
- Comment thread: linear:{issue_id}:c:{comment_id}
- Agent-session issue: linear:{issue_id}:s:{agent_session_id}
- Agent-session comment:
linear:{issue_id}:c:{comment_id}:s:{agent_session_id}

CRITICAL — cross-SDK state compat: the issue-level and comment-thread
outputs are persisted (Redis/Postgres) and shared with the TS SDK, so
they MUST stay byte-identical to the prior forms. The ``:s:`` session
forms are new (no existing persisted data).
"""
if platform_data.agent_session_id:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent session IDs from falling through to comment APIs

With this new branch, any caller can obtain a linear:{issue}:s:{session} or linear:{issue}:c:{comment}:s:{session} thread ID, but the existing public methods that immediately call decode_thread_id are not session-aware: post_message still sends commentCreate with only decoded.issue_id/decoded.comment_id, and fetch_messages falls back to issue/comment fetches. In agent-session mode, a bot reply or fetch against the newly encoded ID will therefore hit the visible issue comments instead of the Linear agent session. Either keep these IDs rejected by those APIs until L4/L5 lands, or add the session-specific handling at the same time.

Useful? React with 👍 / 👎.

if platform_data.comment_id:
return (
f"linear:{platform_data.issue_id}:c:{platform_data.comment_id}:s:{platform_data.agent_session_id}"
)
return f"linear:{platform_data.issue_id}:s:{platform_data.agent_session_id}"

if platform_data.comment_id:
return f"linear:{platform_data.issue_id}:c:{platform_data.comment_id}"
return f"linear:{platform_data.issue_id}"

def decode_thread_id(self, thread_id: str) -> LinearThreadId:
"""Decode a Linear thread ID."""
"""Decode a Linear thread ID.

Patterns are tried most-specific first — COMMENT_SESSION → ISSUE_SESSION
→ COMMENT → bare-issue — exactly as upstream. The order is load-bearing:
a comment-session id ``linear:i:c:cm:s:sess`` also satisfies the bare
anchored shape only via its first segment, and an issue-session id
``linear:i:s:sess`` must not be mistaken for a comment id, so the most
specific anchored pattern must win.
"""
if not thread_id.startswith("linear:"):
raise ValidationError("linear", f"Invalid Linear thread ID: {thread_id}")

without_prefix = thread_id[7:]
if not without_prefix:
raise ValidationError("linear", f"Invalid Linear thread ID format: {thread_id}")

# Check for comment thread format: {issueId}:c:{commentId}
# Agent-session comment format: {issueId}:c:{commentId}:s:{agentSessionId}
comment_session_match = COMMENT_SESSION_THREAD_PATTERN.match(without_prefix)
if comment_session_match:
return LinearThreadId(
issue_id=comment_session_match.group(1),
comment_id=comment_session_match.group(2),
agent_session_id=comment_session_match.group(3),
)

# Agent-session issue format: {issueId}:s:{agentSessionId}
issue_session_match = ISSUE_SESSION_THREAD_PATTERN.match(without_prefix)
if issue_session_match:
return LinearThreadId(
issue_id=issue_session_match.group(1),
agent_session_id=issue_session_match.group(2),
)

# Comment thread format: {issueId}:c:{commentId}
match = COMMENT_THREAD_PATTERN.match(without_prefix)
if match:
return LinearThreadId(
Expand Down
166 changes: 165 additions & 1 deletion tests/test_linear_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@

import pytest

from chat_sdk.adapters.linear.adapter import LinearAdapter
from chat_sdk.adapters.linear.adapter import (
LinearAdapter,
assert_agent_session_thread,
)
from chat_sdk.adapters.linear.types import (
LinearAdapterAPIKeyConfig,
LinearAdapterAppConfig,
LinearAdapterBaseConfig,
LinearAdapterOAuthConfig,
LinearAgentSessionThreadId,
LinearThreadId,
)
from chat_sdk.shared.errors import ValidationError
Expand Down Expand Up @@ -182,6 +186,38 @@ def test_comment_level_uuids(self):
)
assert result == "linear:2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9:c:a1b2c3d4-e5f6-7890-abcd-ef1234567890"

def test_agent_session_issue(self):
# Ported: "should encode an agent-session issue thread ID". When only
# agent_session_id is set the issue session form `:s:` is emitted (the
# bare `:c:` branch must NOT win), so a missing/misordered branch fails.
adapter = _make_adapter()
result = adapter.encode_thread_id(LinearThreadId(issue_id="issue-123", agent_session_id="session-789"))
assert result == "linear:issue-123:s:session-789"

def test_agent_session_comment(self):
# Ported: "should encode an agent-session comment thread ID".
adapter = _make_adapter()
result = adapter.encode_thread_id(
LinearThreadId(
issue_id="issue-123",
comment_id="comment-456",
agent_session_id="session-789",
)
)
assert result == "linear:issue-123:c:comment-456:s:session-789"

def test_existing_forms_byte_identical(self):
# CRITICAL cross-SDK state-compat guard: the issue-level and
# comment-thread outputs are persisted (Redis/Postgres) and shared with
# the TS SDK; adding the session branch must not perturb them by a byte.
# Locks the exact strings independent of the broader round-trip tests.
adapter = _make_adapter()
assert adapter.encode_thread_id(LinearThreadId(issue_id="issue-123")) == "linear:issue-123"
assert (
adapter.encode_thread_id(LinearThreadId(issue_id="issue-123", comment_id="comment-456"))
== "linear:issue-123:c:comment-456"
)


# ---------------------------------------------------------------------------
# decodeThreadId
Expand Down Expand Up @@ -229,6 +265,57 @@ def test_completely_wrong_format(self):
with pytest.raises(ValidationError, match="Invalid Linear thread ID"):
adapter.decode_thread_id("nonsense")

def test_agent_session_issue(self):
# Ported: "should decode an agent-session issue thread ID".
adapter = _make_adapter()
result = adapter.decode_thread_id("linear:issue-123:s:session-789")
assert result.issue_id == "issue-123"
assert result.comment_id is None
assert result.agent_session_id == "session-789"

def test_agent_session_comment(self):
# Ported: "should decode an agent-session comment thread ID".
adapter = _make_adapter()
result = adapter.decode_thread_id("linear:issue-123:c:comment-456:s:session-789")
assert result.issue_id == "issue-123"
assert result.comment_id == "comment-456"
assert result.agent_session_id == "session-789"

def test_comment_session_not_decoded_as_comment(self):
# DECODE-ORDER guard. The COMMENT pattern `^([^:]+):c:([^:]+)$` is
# anchored, so it cannot itself match a trailing `:s:session`. But if a
# reviewer un-anchors COMMENT (drops the `$`) OR moves it ahead of
# COMMENT_SESSION, this id would lose its agent_session_id (or fold the
# session into commentId). Assert the FULL session decode so either
# mutation fails here.
adapter = _make_adapter()
result = adapter.decode_thread_id("linear:issue-123:c:comment-456:s:session-789")
assert result.agent_session_id == "session-789"
assert result.comment_id == "comment-456"
# The comment id must be exactly the middle segment — not the un-anchored
# `comment-456:s:session-789` a leaky regex would capture.
assert result.comment_id != "comment-456:s:session-789"

def test_issue_session_not_decoded_as_bare_issue(self):
# DECODE-ORDER guard. If ISSUE_SESSION is dropped or ordered after the
# bare-issue fallthrough, this id would decode to a single issue_id of
# "issue-123:s:session-789" with no agent_session_id.
adapter = _make_adapter()
result = adapter.decode_thread_id("linear:issue-123:s:session-789")
assert result.issue_id == "issue-123"
assert result.agent_session_id == "session-789"

def test_empty_trailing_session_segment_falls_to_bare_issue(self):
# Malformed `linear:x:s:` — the anchored `([^:]+)` session group rejects
# the empty trailing segment, so (matching upstream byte-for-byte) it is
# NOT a session id and falls through to the bare-issue form with the
# whole remainder as the issue id.
adapter = _make_adapter()
result = adapter.decode_thread_id("linear:x:s:")
assert result.issue_id == "x:s:"
assert result.agent_session_id is None
assert result.comment_id is None


# ---------------------------------------------------------------------------
# Round-trip
Expand All @@ -254,6 +341,83 @@ def test_comment_level(self):
assert decoded.issue_id == original.issue_id
assert decoded.comment_id == original.comment_id

def test_agent_session_comment(self):
# Ported: "should round-trip agent-session comment thread ID".
adapter = _make_adapter()
original = LinearThreadId(
issue_id="issue-123",
comment_id="comment-456",
agent_session_id="session-789",
)
encoded = adapter.encode_thread_id(original)
decoded = adapter.decode_thread_id(encoded)
assert decoded == original

@pytest.mark.parametrize(
"original",
[
LinearThreadId(issue_id="2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9"),
LinearThreadId(
issue_id="2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9",
comment_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
),
LinearThreadId(issue_id="issue-123", agent_session_id="session-789"),
LinearThreadId(
issue_id="issue-123",
comment_id="comment-456",
agent_session_id="session-789",
),
],
ids=["bare-issue", "comment", "issue-session", "comment-session"],
)
def test_encode_decode_encode_identity_all_forms(self, original):
# Property test across ALL four thread-id forms: encode -> decode ->
# encode is the identity on the wire string, and decode -> encode ->
# decode is the identity on the dataclass. A decode-order swap (e.g.
# COMMENT ahead of COMMENT_SESSION) breaks the comment-session row; an
# un-anchored regex breaks the issue-session/comment-session rows.
adapter = _make_adapter()
wire = adapter.encode_thread_id(original)
assert adapter.encode_thread_id(adapter.decode_thread_id(wire)) == wire
assert adapter.decode_thread_id(wire) == original


# ---------------------------------------------------------------------------
# assert_agent_session_thread
# ---------------------------------------------------------------------------


class TestAssertAgentSessionThread:
def test_returns_narrowed_thread_for_session_id(self):
# A thread carrying agent_session_id is accepted and returned re-typed.
thread = LinearThreadId(issue_id="issue-1", agent_session_id="sess-1")
narrowed = assert_agent_session_thread(thread)
assert isinstance(narrowed, LinearThreadId)
assert narrowed.agent_session_id == "sess-1"
assert narrowed.issue_id == "issue-1"
Comment on lines +391 to +397

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

Update the test assertion to verify that the returned object is indeed an instance of LinearAgentSessionThreadId at runtime, aligning with the improved implementation of assert_agent_session_thread.

Suggested change
def test_returns_narrowed_thread_for_session_id(self):
# A thread carrying agent_session_id is accepted and returned re-typed.
thread = LinearThreadId(issue_id="issue-1", agent_session_id="sess-1")
narrowed = assert_agent_session_thread(thread)
assert isinstance(narrowed, LinearThreadId)
assert narrowed.agent_session_id == "sess-1"
assert narrowed.issue_id == "issue-1"
def test_returns_narrowed_thread_for_session_id(self):
# A thread carrying agent_session_id is accepted and returned re-typed.
thread = LinearThreadId(issue_id="issue-1", agent_session_id="sess-1")
narrowed = assert_agent_session_thread(thread)
assert isinstance(narrowed, LinearAgentSessionThreadId)
assert narrowed.agent_session_id == "sess-1"
assert narrowed.issue_id == "issue-1"


def test_accepts_comment_session_thread(self):
thread = LinearThreadId(issue_id="issue-1", comment_id="c-1", agent_session_id="sess-1")
narrowed = assert_agent_session_thread(thread)
assert narrowed.agent_session_id == "sess-1"
assert narrowed.comment_id == "c-1"

def test_raises_on_bare_issue_thread(self):
# The upstream message must match byte-for-byte.
with pytest.raises(ValidationError, match="Expected a Linear agent session thread"):
assert_agent_session_thread(LinearThreadId(issue_id="issue-1"))

def test_raises_on_comment_thread_without_session(self):
with pytest.raises(ValidationError, match="Expected a Linear agent session thread"):
assert_agent_session_thread(LinearThreadId(issue_id="issue-1", comment_id="c-1"))

def test_decode_then_assert_round_trips_for_session_form(self):
# End-to-end: a session wire id decodes to a thread the assertion accepts.
adapter = _make_adapter()
decoded = adapter.decode_thread_id("linear:issue-1:s:sess-1")
narrowed: LinearAgentSessionThreadId = assert_agent_session_thread(decoded)
assert narrowed.agent_session_id == "sess-1"


# ---------------------------------------------------------------------------
# renderFormatted
Expand Down
Loading