-
Notifications
You must be signed in to change notification settings - Fork 1
feat(linear): agent-session thread-id encode/decode (chat@4.31/#151 — L2/5) #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| LinearAdapterBaseConfig, | ||
| LinearAdapterConfig, | ||
| LinearAdapterMode, | ||
| LinearAgentSessionThreadId, | ||
| LinearCommentData, | ||
| LinearCommentRawMessage, | ||
| LinearInstallation, | ||
|
|
@@ -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" | ||
|
|
@@ -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) | ||
|
|
||
|
|
||
| class LinearAdapter: | ||
| """Linear adapter for chat SDK. | ||
|
|
||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With this new branch, any caller can obtain a 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( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the test assertion to verify that the returned object is indeed an instance of
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Python,
typing.castis a static analysis helper and does not perform any runtime type conversion. SinceLinearAgentSessionThreadIdis a distinct subclass ofLinearThreadId, usingcastmeans the returned object is still aLinearThreadIdinstance at runtime. Any subsequent runtime checks (such asisinstance(thread, LinearAgentSessionThreadId)) will fail. To ensure runtime type safety and correct behavior, we should instantiate and return a realLinearAgentSessionThreadIdobject. Additionally, ensure we check ifagent_session_idisNoneexplicitly rather than using a truthiness check, to avoid issues if an empty string is a valid ID.References
is not Noneinstead of a truthiness check to avoid silently ignoring them.