1313
1414import 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+ )
1720from chat_sdk .adapters .linear .types import (
1821 LinearAdapterAPIKeyConfig ,
1922 LinearAdapterAppConfig ,
2023 LinearAdapterBaseConfig ,
2124 LinearAdapterOAuthConfig ,
25+ LinearAgentSessionThreadId ,
2226 LinearThreadId ,
2327)
2428from 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