Skip to content

Commit 8a819f4

Browse files
feat(linear): agent-session thread-id encode/decode (chat@4.31/#151 — L2/5) (#170)
Port the Linear agent-session thread-id forms from upstream adapter-linear/src/index.ts (4.31). Adds two new thread-id shapes alongside the existing issue-level and comment-thread forms: - Agent-session issue: linear:{issueId}:s:{sessionId} - Agent-session comment: linear:{issueId}:c:{commentId}:s:{sessionId} Three anchored patterns (faithful ^...$ ports) are tried most-specific first in decode — COMMENT_SESSION -> ISSUE_SESSION -> COMMENT -> bare — matching upstream order exactly. encode emits the :s: session form when agent_session_id is present. assert_agent_session_thread (ported from utils.ts) raises ValidationError("linear", "Expected a Linear agent session thread") on non-session threads and returns the narrowed type. CRITICAL cross-SDK state compat: the issue-level and comment-thread encode outputs are persisted (Redis/Postgres) shared with the TS SDK and stay byte-identical — verified by a round-trip and an explicit byte-identical guard test. The :s: forms are new (no existing data). Tests: encode/decode for all four forms, an all-forms encode->decode-> encode identity property test, decode-order guards (a comment-session id is NOT mis-decoded as comment; an issue-session id is NOT mis-decoded as bare issue), the malformed `linear:x:s:` edge (falls to bare issue, byte parity with upstream), and assert_agent_session_thread accept/raise. Mutation-checked: dropping the COMMENT_SESSION decode branch, dropping the session encode branch, and un-anchoring COMMENT each fail the suite. Scope is thread-id only — webhook routing (L3), emit (L4), and fetch (L5) are out of scope. L3 consumes decode_thread_id + assert_agent_session_ thread to route AgentSessionEvent webhooks; L4 consumes encode_thread_id to address activity posts back to the session; L5 consumes the decoded agent_session_id for session-scoped fetch. Refs #151 #152
1 parent 8185f57 commit 8a819f4

3 files changed

Lines changed: 241 additions & 4 deletions

File tree

src/chat_sdk/adapters/linear/__init__.py

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

3-
from chat_sdk.adapters.linear.adapter import LinearAdapter, create_linear_adapter
3+
from chat_sdk.adapters.linear.adapter import (
4+
LinearAdapter,
5+
assert_agent_session_thread,
6+
create_linear_adapter,
7+
)
48
from chat_sdk.adapters.linear.types import (
59
AgentSessionEventWebhookPayload,
610
LinearAdapterMode,
@@ -22,5 +26,6 @@
2226
"LinearInstallation",
2327
"LinearRawMessage",
2428
"LinearThreadId",
29+
"assert_agent_session_thread",
2530
"create_linear_adapter",
2631
]

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
LinearAdapterBaseConfig,
2727
LinearAdapterConfig,
2828
LinearAdapterMode,
29+
LinearAgentSessionThreadId,
2930
LinearCommentData,
3031
LinearCommentRawMessage,
3132
LinearInstallation,
@@ -76,7 +77,16 @@
7677
_parse_iso,
7778
)
7879

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

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

105115

116+
def assert_agent_session_thread(
117+
thread: LinearThreadId,
118+
) -> LinearAgentSessionThreadId:
119+
"""Narrow a decoded thread to the agent-session case before session-only work.
120+
121+
Faithful port of upstream ``assertAgentSessionThread`` (``utils.ts``). The TS
122+
signature is ``asserts thread is LinearAgentSessionThreadId`` (a void
123+
type-guard). Python has no in-place assertion narrowing for dataclasses, so
124+
this returns the same thread re-typed as ``LinearAgentSessionThreadId`` —
125+
callers can either ignore the return (assertion side-effect) or bind it to
126+
get the narrowed type. Raises ``ValidationError`` when the thread carries no
127+
``agent_session_id``, matching the upstream message byte-for-byte.
128+
"""
129+
if not thread.agent_session_id:
130+
raise ValidationError("linear", "Expected a Linear agent session thread")
131+
return cast("LinearAgentSessionThreadId", thread)
132+
133+
106134
class LinearAdapter:
107135
"""Linear adapter for chat SDK.
108136
@@ -1052,21 +1080,61 @@ def encode_thread_id(self, platform_data: LinearThreadId) -> str:
10521080
Formats:
10531081
- Issue-level: linear:{issue_id}
10541082
- Comment thread: linear:{issue_id}:c:{comment_id}
1083+
- Agent-session issue: linear:{issue_id}:s:{agent_session_id}
1084+
- Agent-session comment:
1085+
linear:{issue_id}:c:{comment_id}:s:{agent_session_id}
1086+
1087+
CRITICAL — cross-SDK state compat: the issue-level and comment-thread
1088+
outputs are persisted (Redis/Postgres) and shared with the TS SDK, so
1089+
they MUST stay byte-identical to the prior forms. The ``:s:`` session
1090+
forms are new (no existing persisted data).
10551091
"""
1092+
if platform_data.agent_session_id:
1093+
if platform_data.comment_id:
1094+
return (
1095+
f"linear:{platform_data.issue_id}:c:{platform_data.comment_id}:s:{platform_data.agent_session_id}"
1096+
)
1097+
return f"linear:{platform_data.issue_id}:s:{platform_data.agent_session_id}"
1098+
10561099
if platform_data.comment_id:
10571100
return f"linear:{platform_data.issue_id}:c:{platform_data.comment_id}"
10581101
return f"linear:{platform_data.issue_id}"
10591102

10601103
def decode_thread_id(self, thread_id: str) -> LinearThreadId:
1061-
"""Decode a Linear thread ID."""
1104+
"""Decode a Linear thread ID.
1105+
1106+
Patterns are tried most-specific first — COMMENT_SESSION → ISSUE_SESSION
1107+
→ COMMENT → bare-issue — exactly as upstream. The order is load-bearing:
1108+
a comment-session id ``linear:i:c:cm:s:sess`` also satisfies the bare
1109+
anchored shape only via its first segment, and an issue-session id
1110+
``linear:i:s:sess`` must not be mistaken for a comment id, so the most
1111+
specific anchored pattern must win.
1112+
"""
10621113
if not thread_id.startswith("linear:"):
10631114
raise ValidationError("linear", f"Invalid Linear thread ID: {thread_id}")
10641115

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

1069-
# Check for comment thread format: {issueId}:c:{commentId}
1120+
# Agent-session comment format: {issueId}:c:{commentId}:s:{agentSessionId}
1121+
comment_session_match = COMMENT_SESSION_THREAD_PATTERN.match(without_prefix)
1122+
if comment_session_match:
1123+
return LinearThreadId(
1124+
issue_id=comment_session_match.group(1),
1125+
comment_id=comment_session_match.group(2),
1126+
agent_session_id=comment_session_match.group(3),
1127+
)
1128+
1129+
# Agent-session issue format: {issueId}:s:{agentSessionId}
1130+
issue_session_match = ISSUE_SESSION_THREAD_PATTERN.match(without_prefix)
1131+
if issue_session_match:
1132+
return LinearThreadId(
1133+
issue_id=issue_session_match.group(1),
1134+
agent_session_id=issue_session_match.group(2),
1135+
)
1136+
1137+
# Comment thread format: {issueId}:c:{commentId}
10701138
match = COMMENT_THREAD_PATTERN.match(without_prefix)
10711139
if match:
10721140
return LinearThreadId(

tests/test_linear_adapter.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313

1414
import pytest
1515

16-
from chat_sdk.adapters.linear.adapter import LinearAdapter
16+
from chat_sdk.adapters.linear.adapter import (
17+
LinearAdapter,
18+
assert_agent_session_thread,
19+
)
1720
from chat_sdk.adapters.linear.types import (
1821
LinearAdapterAPIKeyConfig,
1922
LinearAdapterAppConfig,
2023
LinearAdapterBaseConfig,
2124
LinearAdapterOAuthConfig,
25+
LinearAgentSessionThreadId,
2226
LinearThreadId,
2327
)
2428
from chat_sdk.shared.errors import ValidationError
@@ -182,6 +186,38 @@ def test_comment_level_uuids(self):
182186
)
183187
assert result == "linear:2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9:c:a1b2c3d4-e5f6-7890-abcd-ef1234567890"
184188

189+
def test_agent_session_issue(self):
190+
# Ported: "should encode an agent-session issue thread ID". When only
191+
# agent_session_id is set the issue session form `:s:` is emitted (the
192+
# bare `:c:` branch must NOT win), so a missing/misordered branch fails.
193+
adapter = _make_adapter()
194+
result = adapter.encode_thread_id(LinearThreadId(issue_id="issue-123", agent_session_id="session-789"))
195+
assert result == "linear:issue-123:s:session-789"
196+
197+
def test_agent_session_comment(self):
198+
# Ported: "should encode an agent-session comment thread ID".
199+
adapter = _make_adapter()
200+
result = adapter.encode_thread_id(
201+
LinearThreadId(
202+
issue_id="issue-123",
203+
comment_id="comment-456",
204+
agent_session_id="session-789",
205+
)
206+
)
207+
assert result == "linear:issue-123:c:comment-456:s:session-789"
208+
209+
def test_existing_forms_byte_identical(self):
210+
# CRITICAL cross-SDK state-compat guard: the issue-level and
211+
# comment-thread outputs are persisted (Redis/Postgres) and shared with
212+
# the TS SDK; adding the session branch must not perturb them by a byte.
213+
# Locks the exact strings independent of the broader round-trip tests.
214+
adapter = _make_adapter()
215+
assert adapter.encode_thread_id(LinearThreadId(issue_id="issue-123")) == "linear:issue-123"
216+
assert (
217+
adapter.encode_thread_id(LinearThreadId(issue_id="issue-123", comment_id="comment-456"))
218+
== "linear:issue-123:c:comment-456"
219+
)
220+
185221

186222
# ---------------------------------------------------------------------------
187223
# decodeThreadId
@@ -229,6 +265,57 @@ def test_completely_wrong_format(self):
229265
with pytest.raises(ValidationError, match="Invalid Linear thread ID"):
230266
adapter.decode_thread_id("nonsense")
231267

268+
def test_agent_session_issue(self):
269+
# Ported: "should decode an agent-session issue thread ID".
270+
adapter = _make_adapter()
271+
result = adapter.decode_thread_id("linear:issue-123:s:session-789")
272+
assert result.issue_id == "issue-123"
273+
assert result.comment_id is None
274+
assert result.agent_session_id == "session-789"
275+
276+
def test_agent_session_comment(self):
277+
# Ported: "should decode an agent-session comment thread ID".
278+
adapter = _make_adapter()
279+
result = adapter.decode_thread_id("linear:issue-123:c:comment-456:s:session-789")
280+
assert result.issue_id == "issue-123"
281+
assert result.comment_id == "comment-456"
282+
assert result.agent_session_id == "session-789"
283+
284+
def test_comment_session_not_decoded_as_comment(self):
285+
# DECODE-ORDER guard. The COMMENT pattern `^([^:]+):c:([^:]+)$` is
286+
# anchored, so it cannot itself match a trailing `:s:session`. But if a
287+
# reviewer un-anchors COMMENT (drops the `$`) OR moves it ahead of
288+
# COMMENT_SESSION, this id would lose its agent_session_id (or fold the
289+
# session into commentId). Assert the FULL session decode so either
290+
# mutation fails here.
291+
adapter = _make_adapter()
292+
result = adapter.decode_thread_id("linear:issue-123:c:comment-456:s:session-789")
293+
assert result.agent_session_id == "session-789"
294+
assert result.comment_id == "comment-456"
295+
# The comment id must be exactly the middle segment — not the un-anchored
296+
# `comment-456:s:session-789` a leaky regex would capture.
297+
assert result.comment_id != "comment-456:s:session-789"
298+
299+
def test_issue_session_not_decoded_as_bare_issue(self):
300+
# DECODE-ORDER guard. If ISSUE_SESSION is dropped or ordered after the
301+
# bare-issue fallthrough, this id would decode to a single issue_id of
302+
# "issue-123:s:session-789" with no agent_session_id.
303+
adapter = _make_adapter()
304+
result = adapter.decode_thread_id("linear:issue-123:s:session-789")
305+
assert result.issue_id == "issue-123"
306+
assert result.agent_session_id == "session-789"
307+
308+
def test_empty_trailing_session_segment_falls_to_bare_issue(self):
309+
# Malformed `linear:x:s:` — the anchored `([^:]+)` session group rejects
310+
# the empty trailing segment, so (matching upstream byte-for-byte) it is
311+
# NOT a session id and falls through to the bare-issue form with the
312+
# whole remainder as the issue id.
313+
adapter = _make_adapter()
314+
result = adapter.decode_thread_id("linear:x:s:")
315+
assert result.issue_id == "x:s:"
316+
assert result.agent_session_id is None
317+
assert result.comment_id is None
318+
232319

233320
# ---------------------------------------------------------------------------
234321
# Round-trip
@@ -254,6 +341,83 @@ def test_comment_level(self):
254341
assert decoded.issue_id == original.issue_id
255342
assert decoded.comment_id == original.comment_id
256343

344+
def test_agent_session_comment(self):
345+
# Ported: "should round-trip agent-session comment thread ID".
346+
adapter = _make_adapter()
347+
original = LinearThreadId(
348+
issue_id="issue-123",
349+
comment_id="comment-456",
350+
agent_session_id="session-789",
351+
)
352+
encoded = adapter.encode_thread_id(original)
353+
decoded = adapter.decode_thread_id(encoded)
354+
assert decoded == original
355+
356+
@pytest.mark.parametrize(
357+
"original",
358+
[
359+
LinearThreadId(issue_id="2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9"),
360+
LinearThreadId(
361+
issue_id="2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9",
362+
comment_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
363+
),
364+
LinearThreadId(issue_id="issue-123", agent_session_id="session-789"),
365+
LinearThreadId(
366+
issue_id="issue-123",
367+
comment_id="comment-456",
368+
agent_session_id="session-789",
369+
),
370+
],
371+
ids=["bare-issue", "comment", "issue-session", "comment-session"],
372+
)
373+
def test_encode_decode_encode_identity_all_forms(self, original):
374+
# Property test across ALL four thread-id forms: encode -> decode ->
375+
# encode is the identity on the wire string, and decode -> encode ->
376+
# decode is the identity on the dataclass. A decode-order swap (e.g.
377+
# COMMENT ahead of COMMENT_SESSION) breaks the comment-session row; an
378+
# un-anchored regex breaks the issue-session/comment-session rows.
379+
adapter = _make_adapter()
380+
wire = adapter.encode_thread_id(original)
381+
assert adapter.encode_thread_id(adapter.decode_thread_id(wire)) == wire
382+
assert adapter.decode_thread_id(wire) == original
383+
384+
385+
# ---------------------------------------------------------------------------
386+
# assert_agent_session_thread
387+
# ---------------------------------------------------------------------------
388+
389+
390+
class TestAssertAgentSessionThread:
391+
def test_returns_narrowed_thread_for_session_id(self):
392+
# A thread carrying agent_session_id is accepted and returned re-typed.
393+
thread = LinearThreadId(issue_id="issue-1", agent_session_id="sess-1")
394+
narrowed = assert_agent_session_thread(thread)
395+
assert isinstance(narrowed, LinearThreadId)
396+
assert narrowed.agent_session_id == "sess-1"
397+
assert narrowed.issue_id == "issue-1"
398+
399+
def test_accepts_comment_session_thread(self):
400+
thread = LinearThreadId(issue_id="issue-1", comment_id="c-1", agent_session_id="sess-1")
401+
narrowed = assert_agent_session_thread(thread)
402+
assert narrowed.agent_session_id == "sess-1"
403+
assert narrowed.comment_id == "c-1"
404+
405+
def test_raises_on_bare_issue_thread(self):
406+
# The upstream message must match byte-for-byte.
407+
with pytest.raises(ValidationError, match="Expected a Linear agent session thread"):
408+
assert_agent_session_thread(LinearThreadId(issue_id="issue-1"))
409+
410+
def test_raises_on_comment_thread_without_session(self):
411+
with pytest.raises(ValidationError, match="Expected a Linear agent session thread"):
412+
assert_agent_session_thread(LinearThreadId(issue_id="issue-1", comment_id="c-1"))
413+
414+
def test_decode_then_assert_round_trips_for_session_form(self):
415+
# End-to-end: a session wire id decodes to a thread the assertion accepts.
416+
adapter = _make_adapter()
417+
decoded = adapter.decode_thread_id("linear:issue-1:s:sess-1")
418+
narrowed: LinearAgentSessionThreadId = assert_agent_session_thread(decoded)
419+
assert narrowed.agent_session_id == "sess-1"
420+
257421

258422
# ---------------------------------------------------------------------------
259423
# renderFormatted

0 commit comments

Comments
 (0)